0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

Merge pull request #2414 from logto-io/merge/suspend

chore: merge branch master into feature/suspend
This commit is contained in:
wangsijie 2022-11-11 17:47:08 +08:00 committed by GitHub
commit fe0d88d41d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
86 changed files with 1852 additions and 727 deletions

View file

@ -0,0 +1,8 @@
---
"@logto/core": minor
"@logto/integration-tests": minor
---
## 💥 Breaking change 💥
Use case-insensitive strategy for searching emails

View file

@ -30,6 +30,7 @@
top: 0;
margin: 0;
opacity: 0%;
cursor: default;
}
input:checked:not(:disabled) ~ .icon > svg:nth-child(1),

View file

@ -1,25 +0,0 @@
@use '@/scss/underscore' as _;
.title {
display: flex;
align-items: center;
.logo {
margin-right: _.unit(2);
width: 20px;
height: 20px;
img {
width: 20px;
height: 20px;
}
}
.icon {
width: 16px;
height: 16px;
object-fit: cover;
margin-left: _.unit(1);
color: var(--color-text-secondary);
}
}

View file

@ -1,80 +0,0 @@
import { ConnectorType } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import Alert from '@/components/Alert';
import Transfer from '@/components/Transfer';
import UnnamedTrans from '@/components/UnnamedTrans';
import useConnectorGroups from '@/hooks/use-connector-groups';
import ConnectorPlatformIcon from '@/icons/ConnectorPlatformIcon';
import * as styles from './ConnectorsTransfer.module.scss';
type Props = {
value: string[];
onChange: (value: string[]) => void;
};
const ConnectorsTransfer = ({ value, onChange }: Props) => {
const { data, error } = useConnectorGroups();
const isLoading = !data && !error;
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
if (isLoading) {
return <div>loading</div>;
}
if (!data && error) {
<div>{`error occurred: ${error.body?.message ?? error.message}`}</div>;
}
const datasource = data
? data
.filter(({ type }) => type === ConnectorType.Social)
.filter(({ enabled }) => enabled)
.map(({ target, name, connectors, logo }) => ({
value: target,
title: (
<div className={styles.title}>
<div className={styles.logo}>
<img src={logo} alt={target} />
</div>
<UnnamedTrans resource={name} />
{connectors.length > 1 &&
connectors
.filter(({ enabled }) => enabled)
.map(({ platform }) => (
<div key={platform} className={styles.icon}>
{platform && <ConnectorPlatformIcon platform={platform} />}
</div>
))}
</div>
),
}))
: [];
return (
<>
{datasource.length > 0 && value.length === 0 && (
<Alert>{t('sign_in_exp.setup_warning.no_added_social_connector')}</Alert>
)}
<Transfer
value={value}
datasource={datasource}
title={t('sign_in_exp.sign_in_methods.transfer.title')}
footer={
<div>
{t('sign_in_exp.sign_in_methods.transfer.footer.not_in_list')}{' '}
<Link to="/connectors/social" target="_blank">
{t('sign_in_exp.sign_in_methods.transfer.footer.set_up_more')}
</Link>{' '}
{t('sign_in_exp.sign_in_methods.transfer.footer.go_to')}
</div>
}
onChange={onChange}
/>
</>
);
};
export default ConnectorsTransfer;

View file

@ -50,6 +50,9 @@ const SignUpForm = () => {
<>
<div className={styles.title}>{t('sign_in_exp.sign_up_and_sign_in.sign_up.title')}</div>
<FormField title="sign_in_exp.sign_up_and_sign_in.sign_up.sign_up_identifier">
<div className={styles.formFieldDescription}>
{t('sign_in_exp.sign_up_and_sign_in.sign_up.identifier_description')}
</div>
<Controller
name="signUp.identifier"
control={control}
@ -92,6 +95,9 @@ const SignUpForm = () => {
title="sign_in_exp.sign_up_and_sign_in.sign_up.sign_up_authentication"
className={styles.signUpAuthentication}
>
<div className={styles.formFieldDescription}>
{t('sign_in_exp.sign_up_and_sign_in.sign_up.authentication_description')}
</div>
<Controller
name="signUp.password"
control={control}

View file

@ -156,9 +156,17 @@ const SignInMethodEditBox = ({
</DragDropProvider>
<ConnectorSetupWarning
requiredConnectors={value
.reduce<ConnectorType[]>((connectors, { identifier: signInIdentifier }) => {
return [...connectors, ...signInIdentifierToRequiredConnectorMapping[signInIdentifier]];
}, [])
.reduce<ConnectorType[]>(
(connectors, { identifier: signInIdentifier, verificationCode }) => {
return [
...connectors,
...(verificationCode
? signInIdentifierToRequiredConnectorMapping[signInIdentifier]
: []),
];
},
[]
)
.filter((connector) => !ignoredWarningConnectors.includes(connector))}
/>
<AddButton

View file

@ -66,7 +66,7 @@ describe('validate sign-in', () => {
});
describe('There must be at least one enabled connector for the specific identifier.', () => {
it('throws when there is no enabled email connector and identifiers includes email', () => {
it('throws when there is no enabled email connector and identifiers includes email with verification code checked', () => {
expect(() => {
validateSignIn(
{
@ -74,6 +74,7 @@ describe('validate sign-in', () => {
{
...mockSignInMethod,
identifier: SignInIdentifier.Email,
verificationCode: true,
},
],
},
@ -88,7 +89,7 @@ describe('validate sign-in', () => {
);
});
it('throws when there is no enabled sms connector and identifiers includes phone', () => {
it('throws when there is no enabled sms connector and identifiers includes phone with verification code checked', () => {
expect(() => {
validateSignIn(
{
@ -96,6 +97,7 @@ describe('validate sign-in', () => {
{
...mockSignInMethod,
identifier: SignInIdentifier.Sms,
verificationCode: true,
},
],
},

View file

@ -11,7 +11,12 @@ export const validateSignIn = (
signUp: SignUp,
enabledConnectors: LogtoConnector[]
) => {
if (signIn.methods.some(({ identifier }) => identifier === SignInIdentifier.Email)) {
if (
signIn.methods.some(
({ identifier, verificationCode }) =>
verificationCode && identifier === SignInIdentifier.Email
)
) {
assertThat(
enabledConnectors.some((item) => item.type === ConnectorType.Email),
new RequestError({
@ -21,7 +26,11 @@ export const validateSignIn = (
);
}
if (signIn.methods.some(({ identifier }) => identifier === SignInIdentifier.Sms)) {
if (
signIn.methods.some(
({ identifier, verificationCode }) => verificationCode && identifier === SignInIdentifier.Sms
)
) {
assertThat(
enabledConnectors.some((item) => item.type === ConnectorType.Sms),
new RequestError({

View file

@ -8,12 +8,7 @@ import { getLogtoConnectorById } from '@/connectors';
import type { SocialUserInfo } from '@/connectors/types';
import { socialUserInfoGuard } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import {
findUserByEmail,
findUserByPhone,
hasUserWithEmail,
hasUserWithPhone,
} from '@/queries/user';
import { findUserByEmail, findUserByPhone } from '@/queries/user';
import assertThat from '@/utils/assert-that';
export type SocialUserInfoSession = {
@ -88,16 +83,20 @@ export const getUserInfoFromInteractionResult = async (
export const findSocialRelatedUser = async (
info: SocialUserInfo
): Promise<Nullable<[{ type: 'email' | 'phone'; value: string }, User]>> => {
if (info.phone && (await hasUserWithPhone(info.phone))) {
if (info.phone) {
const user = await findUserByPhone(info.phone);
return [{ type: 'phone', value: info.phone }, user];
if (user) {
return [{ type: 'phone', value: info.phone }, user];
}
}
if (info.email && (await hasUserWithEmail(info.email))) {
if (info.email) {
const user = await findUserByEmail(info.email);
return [{ type: 'email', value: info.email }, user];
if (user) {
return [{ type: 'email', value: info.email }, user];
}
}
return null;

View file

@ -18,14 +18,14 @@ export const findUserByUsername = async (username: string) =>
`);
export const findUserByEmail = async (email: string) =>
envSet.pool.one<User>(sql`
envSet.pool.maybeOne<User>(sql`
select ${sql.join(Object.values(fields), sql`,`)}
from ${table}
where lower(${fields.primaryEmail})=lower(${email})
`);
export const findUserByPhone = async (phone: string) =>
envSet.pool.one<User>(sql`
envSet.pool.maybeOne<User>(sql`
select ${sql.join(Object.values(fields), sql`,`)}
from ${table}
where ${fields.primaryPhone}=${phone}
@ -47,11 +47,12 @@ export const findUserByIdentity = async (target: string, userId: string) =>
`
);
export const hasUser = async (username: string) =>
export const hasUser = async (username: string, excludeUserId?: string) =>
envSet.pool.exists(sql`
select ${fields.id}
from ${table}
where ${fields.username}=${username}
${conditionalSql(excludeUserId, (id) => sql`and ${fields.id}<>${id}`)}
`);
export const hasUserWithId = async (id: string) =>
@ -61,18 +62,20 @@ export const hasUserWithId = async (id: string) =>
where ${fields.id}=${id}
`);
export const hasUserWithEmail = async (email: string) =>
export const hasUserWithEmail = async (email: string, excludeUserId?: string) =>
envSet.pool.exists(sql`
select ${fields.primaryEmail}
from ${table}
where lower(${fields.primaryEmail})=lower(${email})
${conditionalSql(excludeUserId, (id) => sql`and ${fields.id}<>${id}`)}
`);
export const hasUserWithPhone = async (phone: string) =>
export const hasUserWithPhone = async (phone: string, excludeUserId?: string) =>
envSet.pool.exists(sql`
select ${fields.primaryPhone}
from ${table}
where ${fields.primaryPhone}=${phone}
${conditionalSql(excludeUserId, (id) => sql`and ${fields.id}<>${id}`)}
`);
export const hasUserWithIdentity = async (target: string, userId: string) =>

View file

@ -183,16 +183,29 @@ describe('adminUserRoutes', () => {
});
});
it('PATCH /users/:userId should allow empty avatar URL', async () => {
const name = 'Michael';
const avatar = '';
const response = await userRequest.patch('/users/foo').send({ name, avatar });
it('PATCH /users/:userId should allow empty string for clearable fields', async () => {
const response = await userRequest
.patch('/users/foo')
.send({ name: '', avatar: '', primaryEmail: '' });
expect(response.status).toEqual(200);
expect(response.body).toEqual({
...mockUserResponse,
name,
avatar,
name: '',
avatar: '',
primaryEmail: '',
});
});
it('PATCH /users/:userId should allow null values for clearable fields', async () => {
const response = await userRequest
.patch('/users/foo')
.send({ name: null, username: null, primaryPhone: null });
expect(response.status).toEqual(200);
expect(response.body).toEqual({
...mockUserResponse,
name: null,
username: null,
primaryPhone: null,
});
});

View file

@ -171,10 +171,10 @@ export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
koaGuard({
params: object({ userId: string() }),
body: object({
username: string().regex(usernameRegEx).optional(),
primaryEmail: string().regex(emailRegEx).optional(),
primaryPhone: string().regex(phoneRegEx).optional(),
name: string().nullable().optional(),
username: string().regex(usernameRegEx).or(literal('')).nullable().optional(),
primaryEmail: string().regex(emailRegEx).or(literal('')).nullable().optional(),
primaryPhone: string().regex(phoneRegEx).or(literal('')).nullable().optional(),
name: string().or(literal('')).nullable().optional(),
avatar: string().url().or(literal('')).nullable().optional(),
customData: arbitraryObjectGuard.optional(),
roleNames: string().array().optional(),
@ -187,7 +187,7 @@ export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
} = ctx.guard;
await findUserById(userId);
await checkExistingSignUpIdentifiers(body);
await checkExistingSignUpIdentifiers(body, userId);
// Temp solution to validate the existence of input roleNames
if (body.roleNames?.length) {

View file

@ -23,6 +23,7 @@ import {
getContinueSignInResult,
getRoutePrefix,
getVerificationStorageFromInteraction,
isUserPasswordSet,
} from './utils';
export const continueRoute = getRoutePrefix('sign-in', 'continue');
@ -42,7 +43,7 @@ export default function continueRoutes<T extends AnonymousRouter>(router: T, pro
// Social identities can take place the role of password
assertThat(
!user.passwordEncrypted && Object.keys(user.identities).length === 0,
!isUserPasswordSet(user),
new RequestError({
code: 'user.password_exists',
})

View file

@ -53,12 +53,8 @@ export const smsSignInAction = <StateT, ContextT extends WithLogContext, Respons
checkValidateExpiration(expiresAt);
assertThat(
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 404 })
);
const user = await findUserByPhone(phone);
assertThat(user, new RequestError({ code: 'user.phone_not_exists', status: 404 }));
const { id, isSuspended } = user;
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
ctx.log(type, { userId: id });
@ -100,12 +96,8 @@ export const emailSignInAction = <StateT, ContextT extends WithLogContext, Respo
checkValidateExpiration(expiresAt);
assertThat(
await hasUserWithEmail(email),
new RequestError({ code: 'user.email_not_exists', status: 404 })
);
const user = await findUserByEmail(email);
assertThat(user, new RequestError({ code: 'user.email_not_exists', status: 404 }));
const { id, isSuspended } = user;
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
ctx.log(type, { userId: id });

View file

@ -1,6 +1,7 @@
/* eslint-disable max-lines */
import type { User } from '@logto/schemas';
import { PasscodeType, SignInIdentifier, SignUpIdentifier } from '@logto/schemas';
import type { Nullable } from '@silverhand/essentials';
import { addDays, addSeconds, subDays } from 'date-fns';
import { Provider } from 'oidc-provider';
@ -14,8 +15,8 @@ import passwordlessRoutes, { registerRoute, signInRoute } from './passwordless';
const insertUser = jest.fn(async (..._args: unknown[]) => mockUser);
const findUserById = jest.fn(async (): Promise<User> => mockUser);
const findUserByEmail = jest.fn(async (): Promise<User> => mockUser);
const findUserByPhone = jest.fn(async (): Promise<User> => mockUser);
const findUserByEmail = jest.fn(async (): Promise<Nullable<User>> => mockUser);
const findUserByPhone = jest.fn(async (): Promise<Nullable<User>> => mockUser);
const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser);
const findDefaultSignInExperience = jest.fn(async () => ({
...mockSignInExperience,
@ -261,6 +262,7 @@ describe('session -> passwordlessRoutes', () => {
});
it('throw 404 (with flow `forgot-password`)', async () => {
findUserByPhone.mockResolvedValueOnce(null);
const response = await sessionRequest
.post('/session/passwordless/sms/verify')
.send({ phone: '13000000001', code: '1234', flow: PasscodeType.ForgotPassword });
@ -359,6 +361,7 @@ describe('session -> passwordlessRoutes', () => {
it('throw 404 (with flow `forgot-password`)', async () => {
const fakeTime = new Date();
jest.useFakeTimers().setSystemTime(fakeTime);
findUserByEmail.mockResolvedValueOnce(null);
const response = await sessionRequest
.post('/session/passwordless/email/verify')
.send({ email: 'b@a.com', code: '1234', flow: PasscodeType.ForgotPassword });
@ -502,6 +505,7 @@ describe('session -> passwordlessRoutes', () => {
},
},
});
findUserByPhone.mockResolvedValueOnce(null);
const response = await sessionRequest.post(`${signInRoute}/sms`);
expect(response.statusCode).toEqual(404);
});
@ -658,6 +662,7 @@ describe('session -> passwordlessRoutes', () => {
},
},
});
findUserByEmail.mockResolvedValueOnce(null);
const response = await sessionRequest.post(`${signInRoute}/email`);
expect(response.statusCode).toEqual(404);
});

View file

@ -6,12 +6,7 @@ import { object, string } from 'zod';
import RequestError from '@/errors/RequestError';
import { createPasscode, sendPasscode, verifyPasscode } from '@/lib/passcode';
import koaGuard from '@/middleware/koa-guard';
import {
findUserByEmail,
findUserByPhone,
hasUserWithEmail,
hasUserWithPhone,
} from '@/queries/user';
import { findUserByEmail, findUserByPhone } from '@/queries/user';
import { passcodeTypeGuard } from '@/routes/session/types';
import assertThat from '@/utils/assert-that';
@ -105,13 +100,10 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
await verifyPasscode(jti, flow, code, { phone });
if (flow === PasscodeType.ForgotPassword) {
assertThat(
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 404 })
);
const user = await findUserByPhone(phone);
assertThat(user, new RequestError({ code: 'user.phone_not_exists', status: 404 }));
const { id } = await findUserByPhone(phone);
await assignVerificationResult(ctx, provider, { flow, userId: id });
await assignVerificationResult(ctx, provider, { flow, userId: user.id });
ctx.status = 204;
return next();
@ -156,14 +148,11 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
await verifyPasscode(jti, flow, code, { email });
if (flow === PasscodeType.ForgotPassword) {
assertThat(
await hasUserWithEmail(email),
new RequestError({ code: 'user.email_not_exists', status: 404 })
);
const user = await findUserByEmail(email);
const { id } = await findUserByEmail(email);
assertThat(user, new RequestError({ code: 'user.email_not_exists', status: 404 }));
await assignVerificationResult(ctx, provider, { flow, userId: id });
await assignVerificationResult(ctx, provider, { flow, userId: user.id });
ctx.status = 204;
return next();

View file

@ -152,6 +152,13 @@ export const getContinueSignInResult = async (
return rest;
};
export const isUserPasswordSet = ({
passwordEncrypted,
identities,
}: Pick<User, 'passwordEncrypted' | 'identities'>): boolean => {
return Boolean(passwordEncrypted) || Object.keys(identities).length > 0;
};
/* eslint-disable complexity */
export const checkRequiredProfile = async (
ctx: Context,
@ -160,11 +167,11 @@ export const checkRequiredProfile = async (
signInExperience: SignInExperience
) => {
const { signUp } = signInExperience;
const { passwordEncrypted, id, username, primaryEmail, primaryPhone } = user;
const { id, username, primaryEmail, primaryPhone } = user;
// If check failed, save the sign in result, the user can continue after requirements are meet
if (signUp.password && !passwordEncrypted) {
if (signUp.password && !isUserPasswordSet(user)) {
await assignContinueSignInResult(ctx, provider, { userId: id });
throw new RequestError({ code: 'user.require_password', status: 422 });
}
@ -191,9 +198,9 @@ export const checkRequiredProfile = async (
};
export const checkRequiredSignUpIdentifiers = async (identifiers: {
username?: string;
primaryEmail?: string;
primaryPhone?: string;
username?: Nullable<string>;
primaryEmail?: Nullable<string>;
primaryPhone?: Nullable<string>;
}) => {
const { username, primaryEmail, primaryPhone } = identifiers;
@ -217,22 +224,25 @@ export const checkRequiredSignUpIdentifiers = async (identifiers: {
};
/* eslint-enable complexity */
export const checkExistingSignUpIdentifiers = async (identifiers: {
username?: string;
primaryEmail?: string;
primaryPhone?: string;
}) => {
export const checkExistingSignUpIdentifiers = async (
identifiers: {
username?: Nullable<string>;
primaryEmail?: Nullable<string>;
primaryPhone?: Nullable<string>;
},
excludeUserId: string
) => {
const { username, primaryEmail, primaryPhone } = identifiers;
if (username && (await hasUser(username))) {
if (username && (await hasUser(username, excludeUserId))) {
throw new RequestError({ code: 'user.username_exists', status: 422 });
}
if (primaryEmail && (await hasUserWithEmail(primaryEmail))) {
if (primaryEmail && (await hasUserWithEmail(primaryEmail, excludeUserId))) {
throw new RequestError({ code: 'user.email_exists', status: 422 });
}
if (primaryPhone && (await hasUserWithPhone(primaryPhone))) {
if (primaryPhone && (await hasUserWithPhone(primaryPhone, excludeUserId))) {
throw new RequestError({ code: 'user.sms_exists', status: 422 });
}
};

View file

@ -67,11 +67,22 @@ const translation = {
reset_password_description_sms:
'Gib die Telefonnummer deines Kontos ein und wir senden dir einen Bestätigungscode um dein Passwort zurückzusetzen.',
new_password: 'Neues Passwort',
set_password: 'Set password', // UNTRANSLATED
password_changed: 'Passwort geändert',
no_account: "Don't have an account?", // UNTRANSLATED
have_account: 'Already have an account?', // UNTRANSLATED
enter_password: 'Enter Password', // UNTRANSLATED
enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED
enter_username: 'Enter username', // UNTRANSLATED
enter_username_description:
'Username is an alternative for sign-in. Username must contain only letters, numbers, and underscores.', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
link_phone: 'Link phone', // UNTRANSLATED
link_email_or_phone: 'Link email or phone', // UNTRANSLATED
link_email_description: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED
link_phone_description: 'Link your phone number to sign in or help with account recovery.', // UNTRANSLATED
link_email_or_phone_description:
'Link your email or phone number to sign in or help with account recovery.', // UNTRANSLATED
},
error: {
username_password_mismatch: 'Benutzername oder Passwort ist falsch',

View file

@ -63,11 +63,22 @@ const translation = {
reset_password_description_sms:
'Enter the phone number associated with your account, and well message you the verification code to reset your password.',
new_password: 'New password',
set_password: 'Set password',
password_changed: 'Password Changed',
no_account: "Don't have an account?",
have_account: 'Already have an account?',
enter_password: 'Enter Password',
enter_password_for: 'Enter the password of {{method}} {{value}}',
enter_username: 'Enter username',
enter_username_description:
'Username is an alternative for sign-in. Username must contain only letters, numbers, and underscores.',
link_email: 'Link email',
link_phone: 'Link phone',
link_email_or_phone: 'Link email or phone',
link_email_description: 'Link your email to sign in or help with account recovery.',
link_phone_description: 'Link your phone number to sign in or help with account recovery.',
link_email_or_phone_description:
'Link your email or phone number to sign in or help with account recovery.',
},
error: {
username_password_mismatch: 'Username and password do not match',

View file

@ -67,11 +67,22 @@ const translation = {
reset_password_description_sms:
'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',
set_password: 'Set password', // UNTRANSLATED
password_changed: 'Password Changed', // UNTRANSLATED
no_account: "Don't have an account?", // UNTRANSLATED
have_account: 'Already have an account?', // UNTRANSLATED
enter_password: 'Enter Password', // UNTRANSLATED
enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED
enter_username: 'Enter username', // UNTRANSLATED
enter_username_description:
'Username is an alternative for sign-in. Username must contain only letters, numbers, and underscores.', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
link_phone: 'Link phone', // UNTRANSLATED
link_email_or_phone: 'Link email or phone', // UNTRANSLATED
link_email_description: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED
link_phone_description: 'Link your phone number to sign in or help with account recovery.', // UNTRANSLATED
link_email_or_phone_description:
'Link your email or phone number to sign in or help with account recovery.', // UNTRANSLATED
},
error: {
username_password_mismatch: "Le nom d'utilisateur et le mot de passe ne correspondent pas",

View file

@ -63,11 +63,22 @@ const translation = {
reset_password_description_sms:
'계정과 연결된 전화번호를 입력하면 비밀번호 재설정을 위한 인증 코드를 문자로 보내드립니다.',
new_password: '새 비밀번호',
set_password: 'Set password', // UNTRANSLATED
password_changed: 'Password Changed', // UNTRANSLATED
no_account: "Don't have an account?", // UNTRANSLATED
have_account: 'Already have an account?', // UNTRANSLATED
enter_password: 'Enter Password', // UNTRANSLATED
enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED
enter_username: 'Enter username', // UNTRANSLATED
enter_username_description:
'Username is an alternative for sign-in. Username must contain only letters, numbers, and underscores.', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
link_phone: 'Link phone', // UNTRANSLATED
link_email_or_phone: 'Link email or phone', // UNTRANSLATED
link_email_description: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED
link_phone_description: 'Link your phone number to sign in or help with account recovery.', // UNTRANSLATED
link_email_or_phone_description:
'Link your email or phone number to sign in or help with account recovery.', // UNTRANSLATED
},
error: {
username_password_mismatch: '사용자 이름 또는 비밀번호가 일치하지 않아요.',

View file

@ -63,11 +63,22 @@ const translation = {
reset_password_description_sms:
'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',
set_password: 'Set password', // UNTRANSLATED
password_changed: 'Password Changed', // UNTRANSLATED
no_account: "Don't have an account?", // UNTRANSLATED
have_account: 'Already have an account?', // UNTRANSLATED
enter_password: 'Enter Password', // UNTRANSLATED
enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED
enter_username: 'Enter username', // UNTRANSLATED
enter_username_description:
'Username is an alternative for sign-in. Username must contain only letters, numbers, and underscores.', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
link_phone: 'Link phone', // UNTRANSLATED
link_email_or_phone: 'Link email or phone', // UNTRANSLATED
link_email_description: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED
link_phone_description: 'Link your phone number to sign in or help with account recovery.', // UNTRANSLATED
link_email_or_phone_description:
'Link your email or phone number to sign in or help with account recovery.', // UNTRANSLATED
},
error: {
username_password_mismatch: 'O Utilizador e a password não correspondem',

View file

@ -64,11 +64,22 @@ const translation = {
reset_password_description_sms:
'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',
set_password: 'Set password', // UNTRANSLATED
password_changed: 'Password Changed', // UNTRANSLATED
no_account: "Don't have an account?", // UNTRANSLATED
have_account: 'Already have an account?', // UNTRANSLATED
enter_password: 'Enter Password', // UNTRANSLATED
enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED
enter_username: 'Enter username', // UNTRANSLATED
enter_username_description:
'Username is an alternative for sign-in. Username must contain only letters, numbers, and underscores.', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
link_phone: 'Link phone', // UNTRANSLATED
link_email_or_phone: 'Link email or phone', // UNTRANSLATED
link_email_description: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED
link_phone_description: 'Link your phone number to sign in or help with account recovery.', // UNTRANSLATED
link_email_or_phone_description:
'Link your email or phone number to sign in or help with account recovery.', // UNTRANSLATED
},
error: {
username_password_mismatch: 'Kullanıcı adı ve şifre eşleşmiyor.',

View file

@ -61,11 +61,22 @@ const translation = {
reset_password_description_email: '输入邮件地址,领取验证码以重设密码。',
reset_password_description_sms: '输入手机号,领取验证码以重设密码。',
new_password: '新密码',
set_password: 'Set password', // UNTRANSLATED
password_changed: '已重置密码!',
no_account: "Don't have an account?", // UNTRANSLATED
have_account: 'Already have an account?', // UNTRANSLATED
enter_password: 'Enter Password', // UNTRANSLATED
enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED
enter_username: 'Enter username', // UNTRANSLATED
enter_username_description:
'Username is an alternative for sign-in. Username must contain only letters, numbers, and underscores.', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
link_phone: 'Link phone', // UNTRANSLATED
link_email_or_phone: 'Link email or phone', // UNTRANSLATED
link_email_description: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED
link_phone_description: 'Link your phone number to sign in or help with account recovery.', // UNTRANSLATED
link_email_or_phone_description:
'Link your email or phone number to sign in or help with account recovery.', // UNTRANSLATED
},
error: {
username_password_mismatch: '用户名和密码不匹配',

View file

@ -17,48 +17,52 @@ const sign_in_exp = {
got_it: 'Alles klar',
},
sign_up_and_sign_in: {
identifiers: 'Sign up identifiers', // UNTRANSLATED
identifiers: 'Sign-up identifiers', // UNTRANSLATED
identifiers_email: 'Email address', // UNTRANSLATED
identifiers_sms: 'Phone number', // UNTRANSLATED
identifiers_username: 'Username', // UNTRANSLATED
identifiers_email_or_sms: 'Email address or phone number', // UNTRANSLATED
identifiers_none: 'None', // UNTRANSLATED
identifiers_none: 'Not applicable', // UNTRANSLATED
and: 'and', // UNTRANSLATED
or: 'or', // UNTRANSLATED
sign_up: {
title: 'SIGN UP', // UNTRANSLATED
sign_up_identifier: 'Sign up identifier', // UNTRANSLATED
sign_up_authentication: 'Sign up authentication', // UNTRANSLATED
set_a_password_option: 'Set a password', // UNTRANSLATED
verify_at_sign_up_option: 'Verify at sign up', // UNTRANSLATED
sign_up_identifier: 'Sign-up identifier', // UNTRANSLATED
identifier_description:
'The sign-up identifier is required for account creation and must be included in your sign-in screen.', // UNTRANSLATED
sign_up_authentication: 'Authentication setting for sign-up', // UNTRANSLATED
authentication_description:
'All selected actions will be obligatory for users to complete the flow.', // UNTRANSLATED
set_a_password_option: 'Create your password', // UNTRANSLATED
verify_at_sign_up_option: 'Verify at sign-up', // UNTRANSLATED
social_only_creation_description: '(This apply to social only account creation)', // UNTRANSLATED
},
sign_in: {
title: 'SIGN IN', // UNTRANSLATED
sign_in_identifier_and_auth: 'Sign in identifier and authentication', // UNTRANSLATED
sign_in_identifier_and_auth: 'Identifier and authentication settings for sign-in', // UNTRANSLATED
description:
'Users can use any one of the selected ways to sign in. Drag and drop to define identifier priority regarding the sign in flow. You can also define the password or verification code priority.', // UNTRANSLATED
'Users can sign in using any of the options available. Adjust the layout by drag and dropping below options.', // UNTRANSLATED
add_sign_in_method: 'Add Sign-in Method', // UNTRANSLATED
password_auth: 'Password', // UNTRANSLATED
verification_code_auth: 'Verification code', // UNTRANSLATED
auth_swap_tip: 'Swap to change the priority', // UNTRANSLATED
auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', // UNTRANSLATED
},
social_sign_in: {
title: 'SOCIAL SIGN IN', // UNTRANSLATED
social_sign_in: 'Social sign in', // UNTRANSLATED
title: 'SOCIAL SIGN-IN', // UNTRANSLATED
social_sign_in: 'Social sign-in', // UNTRANSLATED
description:
'Users may need to enter required identifier when register through social accounts. This was defined by your sign up identifier.', // UNTRANSLATED
add_social_connector: 'Add Social Connector', // UNTRANSLATED
'Depending on the mandatory identifier you set up, your user may be asked to provide an identifier when signing up via social connector.', // UNTRANSLATED
add_social_connector: 'Link Social Connector', // UNTRANSLATED
set_up_hint: {
not_in_list: 'Not in the list?', // UNTRANSLATED
set_up_more: 'Set up more', // UNTRANSLATED
go_to: 'social connectors or go to “Connectors” section.', // UNTRANSLATED
set_up_more: 'Set up', // UNTRANSLATED
go_to: 'other social connectors now.', // UNTRANSLATED
},
},
tip: {
set_a_password: 'A unique set of a password to your username is a must.', // UNTRANSLATED
verify_at_sign_up:
'Right now we only support email verified at sign up but soon to open this capability', // UNTRANSLATED
'We currently only support verified email. Your user base may contain a large number of poor-quality email addresses if no validation.', // UNTRANSLATED
password_auth:
'This is essential as you have enabled the option to set a password during the sign-up process.', // UNTRANSLATED
verification_code_auth:
@ -91,28 +95,6 @@ const sign_in_exp = {
slogan: 'Slogan',
slogan_placeholder: 'Entfessle deine Kreativität',
},
sign_in_methods: {
title: 'ANMELDEMETHODEN',
primary: 'Primäre Anmeldemethode',
enable_secondary: 'Aktiviere sekundäre Anmeldemethoden',
enable_secondary_description:
'Sobald sie aktiviert ist, unterstützt deine App neben der primären Anmeldemethode noch weitere Anmeldemethoden. ',
methods: 'Anmeldemethode',
methods_sms: 'SMS Anmeldung',
methods_email: 'E-Mail Anmeldung',
methods_social: 'Social Anmeldung',
methods_username: 'Benutzername-und-Passwort Anmeldung',
methods_primary_tag: '(Primär)',
define_social_methods: 'Definiere die unterstützten Social Anmeldemethoden',
transfer: {
title: 'Social Connectoren',
footer: {
not_in_list: 'Nicht in der Liste?',
set_up_more: 'Mehr Social Connectoren einrichten',
go_to: 'oder "Connectoren" aufrufen.',
},
},
},
others: {
terms_of_use: {
title: 'NUTZUNGSBEDINGUNGEN',
@ -168,21 +150,21 @@ const sign_in_exp = {
setup_warning: {
no_connector: '',
no_connector_sms:
'Du hast noch keinen SMS Connector eingerichtet. Deine Anmeldung wird erst freigeschaltet, wenn du die Einstellungen abgeschlossen hast. ',
'No SMS connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.', // UNTRANSLATED
no_connector_email:
'Du hast noch keinen E-Mail Connector eingerichtet. Deine Anmeldung wird erst freigeschaltet, wenn du die Einstellungen abgeschlossen hast. ',
'No email connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.', // UNTRANSLATED
no_connector_social:
'Du hast noch keinen Social Connector eingerichtet. Deine Anmeldung wird erst freigeschaltet, wenn du die Einstellungen abgeschlossen hast. ',
'No social connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.', // UNTRANSLATED
no_added_social_connector:
'Du hast jetzt ein paar Social Connectoren eingerichtet. Füge jetzt einige zu deinem Anmeldeerlebnis hinzu.',
},
save_alert: {
description:
'Du änderst die Anmeldemethoden. Das wird sich auf einige deiner Benutzer auswirken. Bist du sicher, dass du das tun willst?',
'You are implementing new sign-in and sign-up procedures. All of your users may be affected by the new set-up. Are you sure to commit to the change?', // UNTRANSLATED
before: 'Vorher',
after: 'Nachher',
sign_up: 'Sign up', // UNTRANSLATED
sign_in: 'Sign in', // UNTRANSLATED
sign_up: 'Sign-up', // UNTRANSLATED
sign_in: 'Sign-in', // UNTRANSLATED
social: 'Social', // UNTRANSLATED
},
preview: {

View file

@ -39,48 +39,52 @@ const sign_in_exp = {
slogan_placeholder: 'Unleash your creativity',
},
sign_up_and_sign_in: {
identifiers: 'Sign up identifiers',
identifiers: 'Sign-up identifiers',
identifiers_email: 'Email address',
identifiers_sms: 'Phone number',
identifiers_username: 'Username',
identifiers_email_or_sms: 'Email address or phone number',
identifiers_none: 'None',
identifiers_none: 'Not applicable',
and: 'and',
or: 'or',
sign_up: {
title: 'SIGN UP',
sign_up_identifier: 'Sign up identifier',
sign_up_authentication: 'Sign up authentication',
set_a_password_option: 'Set a password',
verify_at_sign_up_option: 'Verify at sign up',
sign_up_identifier: 'Sign-up identifier',
identifier_description:
'The sign-up identifier is required for account creation and must be included in your sign-in screen.',
sign_up_authentication: 'Authentication setting for sign-up',
authentication_description:
'All selected actions will be obligatory for users to complete the flow.',
set_a_password_option: 'Create your password',
verify_at_sign_up_option: 'Verify at sign-up',
social_only_creation_description: '(This apply to social only account creation)',
},
sign_in: {
title: 'SIGN IN',
sign_in_identifier_and_auth: 'Sign in identifier and authentication',
sign_in_identifier_and_auth: 'Identifier and authentication settings for sign-in',
description:
'Users can use any one of the selected ways to sign in. Drag and drop to define identifier priority regarding the sign in flow. You can also define the password or verification code priority.',
'Users can sign in using any of the options available. Adjust the layout by drag and dropping below options.',
add_sign_in_method: 'Add Sign-in Method',
password_auth: 'Password',
verification_code_auth: 'Verification code',
auth_swap_tip: 'Swap to change the priority',
auth_swap_tip: 'Swap the options below to determine which appears first in the flow.',
},
social_sign_in: {
title: 'SOCIAL SIGN IN',
social_sign_in: 'Social sign in',
title: 'SOCIAL SIGN-IN',
social_sign_in: 'Social sign-in',
description:
'Users may need to enter required identifier when register through social accounts. This was defined by your sign up identifier.',
add_social_connector: 'Add Social Connector',
'Depending on the mandatory identifier you set up, your user may be asked to provide an identifier when signing up via social connector.',
add_social_connector: 'Link Social Connector',
set_up_hint: {
not_in_list: 'Not in the list?',
set_up_more: 'Set up more',
go_to: 'social connectors or go to “Connectors” section.',
set_up_more: 'Set up',
go_to: 'other social connectors now.',
},
},
tip: {
set_a_password: 'A unique set of a password to your username is a must.',
verify_at_sign_up:
'Right now we only support email verified at sign up but soon to open this capability',
'We currently only support verified email. Your user base may contain a large number of poor-quality email addresses if no validation.',
password_auth:
'This is essential as you have enabled the option to set a password during the sign-up process.',
verification_code_auth:
@ -89,28 +93,6 @@ const sign_in_exp = {
'This is essential as you have selected {{identifier}} as a required identifier.',
},
},
sign_in_methods: {
title: 'SIGN-IN METHODS',
primary: 'Primary sign-in method',
enable_secondary: 'Enable secondary sign in',
enable_secondary_description:
"Once it's turned on, you app will support more sign-in method(s) besides the primary one. ",
methods: 'Sign-in method',
methods_sms: 'Phone number sign in',
methods_email: 'Email sign in',
methods_social: 'Social sign in',
methods_username: 'Username-with-password sign in',
methods_primary_tag: '(Primary)',
define_social_methods: 'Define social sign-in methods',
transfer: {
title: 'Social connectors',
footer: {
not_in_list: 'Not in the list?',
set_up_more: 'Set up more',
go_to: 'social connectors or go to “Connectors” section.',
},
},
},
others: {
terms_of_use: {
title: 'TERMS OF USE',
@ -162,21 +144,21 @@ const sign_in_exp = {
setup_warning: {
no_connector: '',
no_connector_sms:
'You havent set up a SMS connector yet. Your sign in experience wont go live until you finish the settings first. ',
'No SMS connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.',
no_connector_email:
'You havent set up an Email connector yet. Your sign in experience wont go live until you finish the settings first. ',
'No email connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.',
no_connector_social:
'You havent set up any social connectors yet. Your sign in experience wont go live until you finish the settings first. ',
'No social connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.',
no_added_social_connector:
'Youve set up a few social connectors now. Make sure to add some to your sign in experience.',
},
save_alert: {
description:
'You are changing sign-in methods. This will impact some of your users. Are you sure you want to do that?',
'You are implementing new sign-in and sign-up procedures. All of your users may be affected by the new set-up. Are you sure to commit to the change?',
before: 'Before',
after: 'After',
sign_up: 'Sign up',
sign_in: 'Sign in',
sign_up: 'Sign-up',
sign_in: 'Sign-in',
social: 'Social',
},
preview: {

View file

@ -41,48 +41,52 @@ const sign_in_exp = {
slogan_placeholder: 'Libérez votre créativité',
},
sign_up_and_sign_in: {
identifiers: 'Sign up identifiers', // UNTRANSLATED
identifiers: 'Sign-up identifiers', // UNTRANSLATED
identifiers_email: 'Email address', // UNTRANSLATED
identifiers_sms: 'Phone number', // UNTRANSLATED
identifiers_username: 'Username', // UNTRANSLATED
identifiers_email_or_sms: 'Email address or phone number', // UNTRANSLATED
identifiers_none: 'None', // UNTRANSLATED
identifiers_none: 'Not applicable', // UNTRANSLATED
and: 'and', // UNTRANSLATED
or: 'or', // UNTRANSLATED
sign_up: {
title: 'SIGN UP', // UNTRANSLATED
sign_up_identifier: 'Sign up identifier', // UNTRANSLATED
sign_up_authentication: 'Sign up authentication', // UNTRANSLATED
set_a_password_option: 'Set a password', // UNTRANSLATED
verify_at_sign_up_option: 'Verify at sign up', // UNTRANSLATED
sign_up_identifier: 'Sign-up identifier', // UNTRANSLATED
identifier_description:
'The sign-up identifier is required for account creation and must be included in your sign-in screen.', // UNTRANSLATED
sign_up_authentication: 'Authentication setting for sign-up', // UNTRANSLATED
authentication_description:
'All selected actions will be obligatory for users to complete the flow.', // UNTRANSLATED
set_a_password_option: 'Create your password', // UNTRANSLATED
verify_at_sign_up_option: 'Verify at sign-up', // UNTRANSLATED
social_only_creation_description: '(This apply to social only account creation)', // UNTRANSLATED
},
sign_in: {
title: 'SIGN IN', // UNTRANSLATED
sign_in_identifier_and_auth: 'Sign in identifier and authentication', // UNTRANSLATED
sign_in_identifier_and_auth: 'Identifier and authentication settings for sign-in', // UNTRANSLATED
description:
'Users can use any one of the selected ways to sign in. Drag and drop to define identifier priority regarding the sign in flow. You can also define the password or verification code priority.', // UNTRANSLATED
'Users can sign in using any of the options available. Adjust the layout by drag and dropping below options.', // UNTRANSLATED
add_sign_in_method: 'Add Sign-in Method', // UNTRANSLATED
password_auth: 'Password', // UNTRANSLATED
verification_code_auth: 'Verification code', // UNTRANSLATED
auth_swap_tip: 'Swap to change the priority', // UNTRANSLATED
auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', // UNTRANSLATED
},
social_sign_in: {
title: 'SOCIAL SIGN IN', // UNTRANSLATED
social_sign_in: 'Social sign in', // UNTRANSLATED
title: 'SOCIAL SIGN-IN', // UNTRANSLATED
social_sign_in: 'Social sign-in', // UNTRANSLATED
description:
'Users may need to enter required identifier when register through social accounts. This was defined by your sign up identifier.', // UNTRANSLATED
add_social_connector: 'Add Social Connector', // UNTRANSLATED
'Depending on the mandatory identifier you set up, your user may be asked to provide an identifier when signing up via social connector.', // UNTRANSLATED
add_social_connector: 'Link Social Connector', // UNTRANSLATED
set_up_hint: {
not_in_list: 'Not in the list?', // UNTRANSLATED
set_up_more: 'Set up more', // UNTRANSLATED
go_to: 'social connectors or go to “Connectors” section.', // UNTRANSLATED
set_up_more: 'Set up', // UNTRANSLATED
go_to: 'other social connectors now.', // UNTRANSLATED
},
},
tip: {
set_a_password: 'A unique set of a password to your username is a must.', // UNTRANSLATED
verify_at_sign_up:
'Right now we only support email verified at sign up but soon to open this capability', // UNTRANSLATED
'We currently only support verified email. Your user base may contain a large number of poor-quality email addresses if no validation.', // UNTRANSLATED
password_auth:
'This is essential as you have enabled the option to set a password during the sign-up process.', // UNTRANSLATED
verification_code_auth:
@ -91,28 +95,6 @@ const sign_in_exp = {
'This is essential as you have selected {{identifier}} as a required identifier.', // UNTRANSLATED
},
},
sign_in_methods: {
title: 'METHODES DE CONNEXION',
primary: 'Méthode de connexion principale',
enable_secondary: 'Activer une seconde méthode de connexion',
enable_secondary_description:
"Une fois qu'elle est activée, votre application prend en charge d'autres méthodes de connexion que la méthode principale. ",
methods: 'Méthode de connexion',
methods_sms: 'Connexion avec numéro de téléphone',
methods_email: 'Connexion avec email',
methods_social: 'Connexion avec social',
methods_username: "Connexion avec nom d'utilisateur et mot de passe",
methods_primary_tag: '(Principale)',
define_social_methods: "Définir les méthodes d'authentification sociale",
transfer: {
title: 'Connecteurs sociaux',
footer: {
not_in_list: 'Pas dans la liste ?',
set_up_more: 'Configurer plus',
go_to: 'connecteurs sociaux ou allez à la section "Connecteurs".',
},
},
},
others: {
terms_of_use: {
title: "CONDITIONS D'UTILISATION",
@ -164,21 +146,21 @@ const sign_in_exp = {
setup_warning: {
no_connector: '',
no_connector_sms:
"Vous n'avez pas encore configuré de connecteur SMS. Votre expérience de connexion ne sera pas disponible tant que vous n'aurez pas terminé les paramètres. ",
'No SMS connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.', // UNTRANSLATED
no_connector_email:
"Vous n'avez pas encore configuré de connecteur Email. Votre expérience de connexion ne sera pas disponible tant que vous n'aurez pas terminé les paramètres. ",
'No email connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.', // UNTRANSLATED
no_connector_social:
"Vous n'avez pas encore configuré de connecteurs sociaux. Votre expérience de connexion ne sera pas disponible tant que vous n'aurez pas terminé les paramètres. ",
'No social connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.', // UNTRANSLATED
no_added_social_connector:
"Vous avez maintenant configuré quelques connecteurs sociaux. Assurez-vous d'en ajouter quelques-uns à votre expérience de connexion.",
},
save_alert: {
description:
'Vous changez de méthode de connexion. Cela aura un impact sur certains de vos utilisateurs. Êtes-vous sûr de vouloir faire cela ?',
'You are implementing new sign-in and sign-up procedures. All of your users may be affected by the new set-up. Are you sure to commit to the change?', // UNTRANSLATED
before: 'Avant',
after: 'Après',
sign_up: 'Sign up', // UNTRANSLATED
sign_in: 'Sign in', // UNTRANSLATED
sign_up: 'Sign-up', // UNTRANSLATED
sign_in: 'Sign-in', // UNTRANSLATED
social: 'Social', // UNTRANSLATED
},
preview: {

View file

@ -36,48 +36,52 @@ const sign_in_exp = {
slogan_placeholder: 'Unleash your creativity',
},
sign_up_and_sign_in: {
identifiers: 'Sign up identifiers', // UNTRANSLATED
identifiers: 'Sign-up identifiers', // UNTRANSLATED
identifiers_email: 'Email address', // UNTRANSLATED
identifiers_sms: 'Phone number', // UNTRANSLATED
identifiers_username: 'Username', // UNTRANSLATED
identifiers_email_or_sms: 'Email address or phone number', // UNTRANSLATED
identifiers_none: 'None', // UNTRANSLATED
identifiers_none: 'Not applicable', // UNTRANSLATED
and: 'and', // UNTRANSLATED
or: 'or', // UNTRANSLATED
sign_up: {
title: 'SIGN UP', // UNTRANSLATED
sign_up_identifier: 'Sign up identifier', // UNTRANSLATED
sign_up_authentication: 'Sign up authentication', // UNTRANSLATED
set_a_password_option: 'Set a password', // UNTRANSLATED
verify_at_sign_up_option: 'Verify at sign up', // UNTRANSLATED
sign_up_identifier: 'Sign-up identifier', // UNTRANSLATED
identifier_description:
'The sign-up identifier is required for account creation and must be included in your sign-in screen.', // UNTRANSLATED
sign_up_authentication: 'Authentication setting for sign-up', // UNTRANSLATED
authentication_description:
'All selected actions will be obligatory for users to complete the flow.', // UNTRANSLATED
set_a_password_option: 'Create your password', // UNTRANSLATED
verify_at_sign_up_option: 'Verify at sign-up', // UNTRANSLATED
social_only_creation_description: '(This apply to social only account creation)', // UNTRANSLATED
},
sign_in: {
title: 'SIGN IN', // UNTRANSLATED
sign_in_identifier_and_auth: 'Sign in identifier and authentication', // UNTRANSLATED
sign_in_identifier_and_auth: 'Identifier and authentication settings for sign-in', // UNTRANSLATED
description:
'Users can use any one of the selected ways to sign in. Drag and drop to define identifier priority regarding the sign in flow. You can also define the password or verification code priority.', // UNTRANSLATED
'Users can sign in using any of the options available. Adjust the layout by drag and dropping below options.', // UNTRANSLATED
add_sign_in_method: 'Add Sign-in Method', // UNTRANSLATED
password_auth: 'Password', // UNTRANSLATED
verification_code_auth: 'Verification code', // UNTRANSLATED
auth_swap_tip: 'Swap to change the priority', // UNTRANSLATED
auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', // UNTRANSLATED
},
social_sign_in: {
title: 'SOCIAL SIGN IN', // UNTRANSLATED
social_sign_in: 'Social sign in', // UNTRANSLATED
title: 'SOCIAL SIGN-IN', // UNTRANSLATED
social_sign_in: 'Social sign-in', // UNTRANSLATED
description:
'Users may need to enter required identifier when register through social accounts. This was defined by your sign up identifier.', // UNTRANSLATED
add_social_connector: 'Add Social Connector', // UNTRANSLATED
'Depending on the mandatory identifier you set up, your user may be asked to provide an identifier when signing up via social connector.', // UNTRANSLATED
add_social_connector: 'Link Social Connector', // UNTRANSLATED
set_up_hint: {
not_in_list: 'Not in the list?', // UNTRANSLATED
set_up_more: 'Set up more', // UNTRANSLATED
go_to: 'social connectors or go to “Connectors” section.', // UNTRANSLATED
set_up_more: 'Set up', // UNTRANSLATED
go_to: 'other social connectors now.', // UNTRANSLATED
},
},
tip: {
set_a_password: 'A unique set of a password to your username is a must.', // UNTRANSLATED
verify_at_sign_up:
'Right now we only support email verified at sign up but soon to open this capability', // UNTRANSLATED
'We currently only support verified email. Your user base may contain a large number of poor-quality email addresses if no validation.', // UNTRANSLATED
password_auth:
'This is essential as you have enabled the option to set a password during the sign-up process.', // UNTRANSLATED
verification_code_auth:
@ -86,28 +90,6 @@ const sign_in_exp = {
'This is essential as you have selected {{identifier}} as a required identifier.', // UNTRANSLATED
},
},
sign_in_methods: {
title: '로그인 방법',
primary: '메인 로그인 방법',
enable_secondary: '백업 로그인 방법 활성화',
enable_secondary_description:
'백업 로그인 활성화를 통하여 메인 로그인 방법이외의 로그인 방법을 사용자에게 제공해보세요.',
methods: '로그인 방법',
methods_sms: 'SMS 로그인',
methods_email: '이메일 로그인',
methods_social: '소셜 로그인',
methods_username: '사용자 이름&비밀번호 로그인',
methods_primary_tag: '(메인)',
define_social_methods: '소셜 로그인 방법 설정',
transfer: {
title: '소셜 연동',
footer: {
not_in_list: '리스트에 없나요?',
set_up_more: '더 설정하기',
go_to: '를 눌러 설정하러 가기',
},
},
},
others: {
terms_of_use: {
title: '이용 약관',
@ -159,21 +141,21 @@ const sign_in_exp = {
setup_warning: {
no_connector: '',
no_connector_sms:
'SMS 연동이 아직 설정되지 않았어요. 설정이 완료될 때 까지, 사용자는 이 로그인 방법을 사용할 수 없어요.',
'No SMS connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.', // UNTRANSLATED
no_connector_email:
'이메일 연동이 아직 설정되지 않았어요. 설정이 완료될 때 까지, 사용자는 이 로그인 방법을 사용할 수 없어요.',
'No email connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.', // UNTRANSLATED
no_connector_social:
'소셜 연동이 아직 설정되지 않았어요. 설정이 완료될 때 까지, 사용자는 이 로그인 방법을 사용할 수 없어요.',
'No social connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.', // UNTRANSLATED
no_added_social_connector:
'보다 많은 소셜 연동들을 설정하여, 고객에게 보다 나은 경험을 제공해보세요.',
},
save_alert: {
description:
'로그인 방법이 수정되었어요. 일부 사용자에게 영향을 미칠 수 있어요. 정말로 진행할까요?',
'You are implementing new sign-in and sign-up procedures. All of your users may be affected by the new set-up. Are you sure to commit to the change?', // UNTRANSLATED
before: '이전',
after: '이후',
sign_up: 'Sign up', // UNTRANSLATED
sign_in: 'Sign in', // UNTRANSLATED
sign_up: 'Sign-up', // UNTRANSLATED
sign_in: 'Sign-in', // UNTRANSLATED
social: 'Social', // UNTRANSLATED
},
preview: {

View file

@ -39,48 +39,52 @@ const sign_in_exp = {
slogan_placeholder: 'Liberte a sua criatividade',
},
sign_up_and_sign_in: {
identifiers: 'Sign up identifiers', // UNTRANSLATED
identifiers: 'Sign-up identifiers', // UNTRANSLATED
identifiers_email: 'Email address', // UNTRANSLATED
identifiers_sms: 'Phone number', // UNTRANSLATED
identifiers_username: 'Username', // UNTRANSLATED
identifiers_email_or_sms: 'Email address or phone number', // UNTRANSLATED
identifiers_none: 'None', // UNTRANSLATED
identifiers_none: 'Not applicable', // UNTRANSLATED
and: 'and', // UNTRANSLATED
or: 'or', // UNTRANSLATED
sign_up: {
title: 'SIGN UP', // UNTRANSLATED
sign_up_identifier: 'Sign up identifier', // UNTRANSLATED
sign_up_authentication: 'Sign up authentication', // UNTRANSLATED
set_a_password_option: 'Set a password', // UNTRANSLATED
verify_at_sign_up_option: 'Verify at sign up', // UNTRANSLATED
sign_up_identifier: 'Sign-up identifier', // UNTRANSLATED
identifier_description:
'The sign-up identifier is required for account creation and must be included in your sign-in screen.', // UNTRANSLATED
sign_up_authentication: 'Authentication setting for sign-up', // UNTRANSLATED
authentication_description:
'All selected actions will be obligatory for users to complete the flow.', // UNTRANSLATED
set_a_password_option: 'Create your password', // UNTRANSLATED
verify_at_sign_up_option: 'Verify at sign-up', // UNTRANSLATED
social_only_creation_description: '(This apply to social only account creation)', // UNTRANSLATED
},
sign_in: {
title: 'SIGN IN', // UNTRANSLATED
sign_in_identifier_and_auth: 'Sign in identifier and authentication', // UNTRANSLATED
sign_in_identifier_and_auth: 'Identifier and authentication settings for sign-in', // UNTRANSLATED
description:
'Users can use any one of the selected ways to sign in. Drag and drop to define identifier priority regarding the sign in flow. You can also define the password or verification code priority.', // UNTRANSLATED
'Users can sign in using any of the options available. Adjust the layout by drag and dropping below options.', // UNTRANSLATED
add_sign_in_method: 'Add Sign-in Method', // UNTRANSLATED
password_auth: 'Password', // UNTRANSLATED
verification_code_auth: 'Verification code', // UNTRANSLATED
auth_swap_tip: 'Swap to change the priority', // UNTRANSLATED
auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', // UNTRANSLATED
},
social_sign_in: {
title: 'SOCIAL SIGN IN', // UNTRANSLATED
social_sign_in: 'Social sign in', // UNTRANSLATED
title: 'SOCIAL SIGN-IN', // UNTRANSLATED
social_sign_in: 'Social sign-in', // UNTRANSLATED
description:
'Users may need to enter required identifier when register through social accounts. This was defined by your sign up identifier.', // UNTRANSLATED
add_social_connector: 'Add Social Connector', // UNTRANSLATED
'Depending on the mandatory identifier you set up, your user may be asked to provide an identifier when signing up via social connector.', // UNTRANSLATED
add_social_connector: 'Link Social Connector', // UNTRANSLATED
set_up_hint: {
not_in_list: 'Not in the list?', // UNTRANSLATED
set_up_more: 'Set up more', // UNTRANSLATED
go_to: 'social connectors or go to “Connectors” section.', // UNTRANSLATED
set_up_more: 'Set up', // UNTRANSLATED
go_to: 'other social connectors now.', // UNTRANSLATED
},
},
tip: {
set_a_password: 'A unique set of a password to your username is a must.', // UNTRANSLATED
verify_at_sign_up:
'Right now we only support email verified at sign up but soon to open this capability', // UNTRANSLATED
'We currently only support verified email. Your user base may contain a large number of poor-quality email addresses if no validation.', // UNTRANSLATED
password_auth:
'This is essential as you have enabled the option to set a password during the sign-up process.', // UNTRANSLATED
verification_code_auth:
@ -89,28 +93,6 @@ const sign_in_exp = {
'This is essential as you have selected {{identifier}} as a required identifier.', // UNTRANSLATED
},
},
sign_in_methods: {
title: 'MÉTODOS DE LOGIN',
primary: 'Método de login principal',
enable_secondary: 'Ativar login secundário',
enable_secondary_description:
'Depois de ativado, a sua app oferecerá suporte a mais métodos de login além do principal. ',
methods: 'Método de login',
methods_sms: 'SMS',
methods_email: 'Email',
methods_social: 'Rede social',
methods_username: 'Utilizador e password',
methods_primary_tag: '(Primário)',
define_social_methods: 'Definir métodos de login social',
transfer: {
title: 'Conectores sociais',
footer: {
not_in_list: 'Não está na lista?',
set_up_more: 'Configurar mais',
go_to: 'conectores sociais ou vá para a seção "Conectores".',
},
},
},
others: {
terms_of_use: {
title: 'TERMOS DE USO',
@ -162,21 +144,21 @@ const sign_in_exp = {
setup_warning: {
no_connector: '',
no_connector_sms:
'Ainda não configurou um conector de SMS. A experiência de login não será ativada até que conclua as configurações primeiro. ',
'No SMS connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.', // UNTRANSLATED
no_connector_email:
'Ainda não configurou um conector de email. A experiência de login não será ativada até que conclua as configurações primeiro. ',
'No email connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.', // UNTRANSLATED
no_connector_social:
'Ainda não configurou um conector social. A experiência de login não será ativada até que conclua as configurações primeiro. ',
'No social connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.', // UNTRANSLATED
no_added_social_connector:
'Configurou alguns conectores sociais agora. Certifique-se de adicionar alguns a experiência de login.',
},
save_alert: {
description:
'Está alterando os métodos de login. Isso afetará alguns dos seus utilizadoress. Tem a certeza que deseja fazer isso?',
'You are implementing new sign-in and sign-up procedures. All of your users may be affected by the new set-up. Are you sure to commit to the change?', // UNTRANSLATED
before: 'Antes',
after: 'Depois',
sign_up: 'Sign up', // UNTRANSLATED
sign_in: 'Sign in', // UNTRANSLATED
sign_up: 'Sign-up', // UNTRANSLATED
sign_in: 'Sign-in', // UNTRANSLATED
social: 'Social', // UNTRANSLATED
},
preview: {

View file

@ -40,48 +40,52 @@ const sign_in_exp = {
slogan_placeholder: 'Yaratıcılığınızıığa çıkarın',
},
sign_up_and_sign_in: {
identifiers: 'Sign up identifiers', // UNTRANSLATED
identifiers: 'Sign-up identifiers', // UNTRANSLATED
identifiers_email: 'Email address', // UNTRANSLATED
identifiers_sms: 'Phone number', // UNTRANSLATED
identifiers_username: 'Username', // UNTRANSLATED
identifiers_email_or_sms: 'Email address or phone number', // UNTRANSLATED
identifiers_none: 'None', // UNTRANSLATED
identifiers_none: 'Not applicable', // UNTRANSLATED
and: 'and', // UNTRANSLATED
or: 'or', // UNTRANSLATED
sign_up: {
title: 'SIGN UP', // UNTRANSLATED
sign_up_identifier: 'Sign up identifier', // UNTRANSLATED
sign_up_authentication: 'Sign up authentication', // UNTRANSLATED
set_a_password_option: 'Set a password', // UNTRANSLATED
verify_at_sign_up_option: 'Verify at sign up', // UNTRANSLATED
sign_up_identifier: 'Sign-up identifier', // UNTRANSLATED
identifier_description:
'The sign-up identifier is required for account creation and must be included in your sign-in screen.', // UNTRANSLATED
sign_up_authentication: 'Authentication setting for sign-up', // UNTRANSLATED
authentication_description:
'All selected actions will be obligatory for users to complete the flow.', // UNTRANSLATED
set_a_password_option: 'Create your password', // UNTRANSLATED
verify_at_sign_up_option: 'Verify at sign-up', // UNTRANSLATED
social_only_creation_description: '(This apply to social only account creation)', // UNTRANSLATED
},
sign_in: {
title: 'SIGN IN', // UNTRANSLATED
sign_in_identifier_and_auth: 'Sign in identifier and authentication', // UNTRANSLATED
sign_in_identifier_and_auth: 'Identifier and authentication settings for sign-in', // UNTRANSLATED
description:
'Users can use any one of the selected ways to sign in. Drag and drop to define identifier priority regarding the sign in flow. You can also define the password or verification code priority.', // UNTRANSLATED
'Users can sign in using any of the options available. Adjust the layout by drag and dropping below options.', // UNTRANSLATED
add_sign_in_method: 'Add Sign-in Method', // UNTRANSLATED
password_auth: 'Password', // UNTRANSLATED
verification_code_auth: 'Verification code', // UNTRANSLATED
auth_swap_tip: 'Swap to change the priority', // UNTRANSLATED
auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', // UNTRANSLATED
},
social_sign_in: {
title: 'SOCIAL SIGN IN', // UNTRANSLATED
social_sign_in: 'Social sign in', // UNTRANSLATED
title: 'SOCIAL SIGN-IN', // UNTRANSLATED
social_sign_in: 'Social sign-in', // UNTRANSLATED
description:
'Users may need to enter required identifier when register through social accounts. This was defined by your sign up identifier.', // UNTRANSLATED
add_social_connector: 'Add Social Connector', // UNTRANSLATED
'Depending on the mandatory identifier you set up, your user may be asked to provide an identifier when signing up via social connector.', // UNTRANSLATED
add_social_connector: 'Link Social Connector', // UNTRANSLATED
set_up_hint: {
not_in_list: 'Not in the list?', // UNTRANSLATED
set_up_more: 'Set up more', // UNTRANSLATED
go_to: 'social connectors or go to “Connectors” section.', // UNTRANSLATED
set_up_more: 'Set up', // UNTRANSLATED
go_to: 'other social connectors now.', // UNTRANSLATED
},
},
tip: {
set_a_password: 'A unique set of a password to your username is a must.', // UNTRANSLATED
verify_at_sign_up:
'Right now we only support email verified at sign up but soon to open this capability', // UNTRANSLATED
'We currently only support verified email. Your user base may contain a large number of poor-quality email addresses if no validation.', // UNTRANSLATED
password_auth:
'This is essential as you have enabled the option to set a password during the sign-up process.', // UNTRANSLATED
verification_code_auth:
@ -90,28 +94,6 @@ const sign_in_exp = {
'This is essential as you have selected {{identifier}} as a required identifier.', // UNTRANSLATED
},
},
sign_in_methods: {
title: 'OTURUM AÇMA YÖNTEMLERİ',
primary: 'Birincil oturum açma yöntemi',
enable_secondary: 'İkincil oturum açmayı etkinleştir',
enable_secondary_description:
'Açıldığında, uygulamanız birincil yöntemin yanı sıra daha fazla oturum açma yöntemini destekleyecektir. ',
methods: 'Oturum açma yöntemi',
methods_sms: 'Telefon numarası girişi',
methods_email: 'E-posta adresi girişi',
methods_social: 'Sosyal platform girişi',
methods_username: 'Kullanıcı adı ve şifre ile oturum açma',
methods_primary_tag: '(Primary)',
define_social_methods: 'Sosyal platform oturum açma yöntemlerini tanımlama',
transfer: {
title: 'Social connectorlar',
footer: {
not_in_list: 'Listede yok?',
set_up_more: 'Daha fazlasını ayarla',
go_to: 'Social connectorlara veya “Connectors” bölümüne git.',
},
},
},
others: {
terms_of_use: {
title: 'KULLANIM KOŞULLARI',
@ -163,21 +145,21 @@ const sign_in_exp = {
setup_warning: {
no_connector: '',
no_connector_sms:
'Henüz bir SMS bağlayıcısı kurmadınız. Öncelikle ayarları tamamlayana kadar oturum açma deneyiminiz yayınlanmayacaktır. ',
'No SMS connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.', // UNTRANSLATED
no_connector_email:
'Henüz bir e-posta adresi bağlayıcısı kurmadınız. Öncelikle ayarları tamamlayana kadar oturum açma deneyiminiz yayınlanmayacaktır. ',
'No email connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.', // UNTRANSLATED
no_connector_social:
'Henüz herhangi bir social connector kurmadınız. Öncelikle ayarları tamamlayana kadar oturum açma deneyiminiz yayınlanmayacaktır. ',
'No social connector set-up yet. Until you finish configuring your social connector, you wont be able to sign in.', // UNTRANSLATED
no_added_social_connector:
'Şimdi birkaç social connector kurdunuz. Oturum açma deneyiminize bazı şeyler eklediğinizden emin olun.',
},
save_alert: {
description:
'Oturum açma yöntemlerini değiştiriyorsunuz. Bu, bazı kullanıcılarınızı etkileyecektir. Bunu yapmak istediğine emin misin?',
'You are implementing new sign-in and sign-up procedures. All of your users may be affected by the new set-up. Are you sure to commit to the change?', // UNTRANSLATED
before: 'Önce',
after: 'Sonra',
sign_up: 'Sign up', // UNTRANSLATED
sign_in: 'Sign in', // UNTRANSLATED
sign_up: 'Sign-up', // UNTRANSLATED
sign_in: 'Sign-in', // UNTRANSLATED
social: 'Social', // UNTRANSLATED
},
preview: {

View file

@ -3,7 +3,7 @@ const sign_in_exp = {
description: '自定义登录界面,并实时预览真实效果',
tabs: {
branding: '品牌',
sign_up_and_sign_in: 'Sign up and Sign in', // UNTRANSLATED
sign_up_and_sign_in: '注册与登录',
others: '其它',
},
welcome: {
@ -37,75 +37,53 @@ const sign_in_exp = {
slogan_placeholder: '释放你的创意',
},
sign_up_and_sign_in: {
identifiers: 'Sign up identifiers', // UNTRANSLATED
identifiers_email: 'Email address', // UNTRANSLATED
identifiers_sms: 'Phone number', // UNTRANSLATED
identifiers_username: 'Username', // UNTRANSLATED
identifiers_email_or_sms: 'Email address or phone number', // UNTRANSLATED
identifiers_none: 'None', // UNTRANSLATED
and: 'and', // UNTRANSLATED
or: 'or', // UNTRANSLATED
identifiers: '注册标识',
identifiers_email: '邮件地址',
identifiers_sms: '手机号码',
identifiers_username: '用户名',
identifiers_email_or_sms: '邮件地址或手机号码',
identifiers_none: '无',
and: '与',
or: '或',
sign_up: {
title: 'SIGN UP', // UNTRANSLATED
sign_up_identifier: 'Sign up identifier', // UNTRANSLATED
sign_up_authentication: 'Sign up authentication', // UNTRANSLATED
set_a_password_option: 'Set a password', // UNTRANSLATED
verify_at_sign_up_option: 'Verify at sign up', // UNTRANSLATED
social_only_creation_description: '(This apply to social only account creation)', // UNTRANSLATED
title: '注册',
sign_up_identifier: '注册标识',
identifier_description: '创建账户时你需要设定注册标识。这些信息在用户登录时,属于必选项。',
sign_up_authentication: '注册身份认证设置',
authentication_description: '注册时,你的用户将要完成以下所有勾选的任务。',
set_a_password_option: '创建密码',
verify_at_sign_up_option: '注册时验证身份',
social_only_creation_description: '(仅对社交注册用户适用)',
},
sign_in: {
title: 'SIGN IN', // UNTRANSLATED
sign_in_identifier_and_auth: 'Sign in identifier and authentication', // UNTRANSLATED
description:
'Users can use any one of the selected ways to sign in. Drag and drop to define identifier priority regarding the sign in flow. You can also define the password or verification code priority.', // UNTRANSLATED
add_sign_in_method: 'Add Sign-in Method', // UNTRANSLATED
password_auth: 'Password', // UNTRANSLATED
verification_code_auth: 'Verification code', // UNTRANSLATED
auth_swap_tip: 'Swap to change the priority', // UNTRANSLATED
title: '登录',
sign_in_identifier_and_auth: '登录标识和身份认证设置',
description: '用户可以使用任何可用的选项进行登录。拖拽选项即可调整页面布局。',
add_sign_in_method: '添加登录方式',
password_auth: '密码',
verification_code_auth: '验证码',
auth_swap_tip: '交换以下选项的位置即可设定它们在用户登录流程中出现的先后。',
},
social_sign_in: {
title: 'SOCIAL SIGN IN', // UNTRANSLATED
social_sign_in: 'Social sign in', // UNTRANSLATED
description:
'Users may need to enter required identifier when register through social accounts. This was defined by your sign up identifier.', // UNTRANSLATED
add_social_connector: 'Add Social Connector', // UNTRANSLATED
title: '社交登录',
social_sign_in: '社交登录',
description: '你已设定特定的标识。用户在通过社交连接器注册时可能会被要求提供一个对应的标识。',
add_social_connector: '添加社交连接器',
set_up_hint: {
not_in_list: 'Not in the list?', // UNTRANSLATED
set_up_more: 'Set up more', // UNTRANSLATED
go_to: 'social connectors or go to “Connectors” section.', // UNTRANSLATED
not_in_list: '没有你想要的连接器?',
set_up_more: '立即设置',
go_to: '其他社交连接器。',
},
},
tip: {
set_a_password: 'A unique set of a password to your username is a must.', // UNTRANSLATED
set_a_password: '启用户名注册,必须设置密码。',
verify_at_sign_up:
'Right now we only support email verified at sign up but soon to open this capability', // UNTRANSLATED
password_auth:
'This is essential as you have enabled the option to set a password during the sign-up process.', // UNTRANSLATED
'我们目前仅支持经过验证的邮件地址登录。如果没有验证,你的用户信息中可能出现大量无效电子邮件地址。',
password_auth: '因注册设置里你启用了用户名密码标识。这个信息在用户登录时,属于必选项。',
verification_code_auth:
'This is essential as you have only enabled the option to provide verification code when signing up. Youre free to uncheck the box when password set-up is allowed at the sign-up process.', // UNTRANSLATED
'因注册设置里你启用了验证码标识,验证码属于用户必选项。开启密码注册后,你可以选择关闭验证码登录。',
delete_sign_in_method:
'This is essential as you have selected {{identifier}} as a required identifier.', // UNTRANSLATED
},
},
sign_in_methods: {
title: '登录方式',
primary: '主要登录方式',
enable_secondary: '启用其它登录方式',
enable_secondary_description: '开启后,除了主要登录方式,你的 app 将会支持更多其它的登录方式 ',
methods: '登录方式',
methods_sms: '手机号登录',
methods_email: '邮箱登录',
methods_social: '社交帐号登录',
methods_username: '用户名密码登录',
methods_primary_tag: '(主要)',
define_social_methods: '定义社交登录方式',
transfer: {
title: '社交连接器',
footer: {
not_in_list: '不在列表里?',
set_up_more: '设置更多',
go_to: '社交连接器,或前往连接器模块进行设置。',
},
'因注册设置里你启用了{{identifier}}标识。这些信息在用户登录时,属于必选项。',
},
},
others: {
@ -156,18 +134,19 @@ const sign_in_exp = {
},
setup_warning: {
no_connector: '',
no_connector_sms: '你还没有设置 SMS 连接器。你需完成设置后登录体验才会生效。',
no_connector_email: '你还没有设置 email 连接器。你需完成设置后登录体验才会生效。',
no_connector_social: '你还没有设置社交连接器。你需完成设置后登录体验才会生效。',
no_connector_sms: '你尚未设置 SMS 短信连接器。在完成该配置前,你将无法登录。',
no_connector_email: '你尚未设置电子邮件连接器。在完成该配置前,你将无法登录。',
no_connector_social: '你尚未设置社交连接器。在完成该配置前,你将无法登录。',
no_added_social_connector: '你已经成功设置了一些社交连接器。点按「+」添加一些到你的登录体验。',
},
save_alert: {
description: '你正在修改登录方式,这可能会影响部分用户。是否继续保存修改?',
before: '修改前',
after: '修改后',
sign_up: 'Sign up', // UNTRANSLATED
sign_in: 'Sign in', // UNTRANSLATED
social: 'Social', // UNTRANSLATED
description:
'你正在进行登录注册设置的变更。当前你的所有用户会受到新设置的影响。确认保存该设置吗?',
before: '设置前',
after: '设置后',
sign_up: '注册',
sign_in: '登录',
social: '社交',
},
preview: {
title: '登录预览',

View file

@ -9,6 +9,7 @@ import usePreview from './hooks/use-preview';
import initI18n from './i18n/init';
import Callback from './pages/Callback';
import Consent from './pages/Consent';
import Continue from './pages/Continue';
import ErrorPage from './pages/ErrorPage';
import ForgotPassword from './pages/ForgotPassword';
import Passcode from './pages/Passcode';
@ -67,7 +68,7 @@ const App = () => {
/>
<Route element={<LoadingLayerProvider />}>
{/* sign-in */}
{/* Sign-in */}
<Route
path="/sign-in"
element={isRegisterOnly ? <Navigate replace to="/register" /> : <SignIn />}
@ -76,7 +77,7 @@ const App = () => {
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
<Route path="/sign-in/:method/password" element={<SignInPassword />} />
{/* register */}
{/* Register */}
<Route
path="/register"
element={isSignInOnly ? <Navigate replace to="/sign-in" /> : <Register />}
@ -87,17 +88,19 @@ const App = () => {
/>
<Route path="/register/:method" element={<SecondaryRegister />} />
{/* forgot password */}
{/* Forgot password */}
<Route path="/forgot-password/reset" element={<ResetPassword />} />
<Route path="/forgot-password/:method" element={<ForgotPassword />} />
{/* social sign-in pages */}
{/* Continue set up missing profile */}
<Route path="/continue/:method" element={<Continue />} />
{/* Social sign-in pages */}
<Route path="/callback/:connector" element={<Callback />} />
<Route path="/social/register/:connector" element={<SocialRegister />} />
<Route path="/social/landing/:connector" element={<SocialLanding />} />
{/* always keep route path with param as the last one */}
{/* Always keep route path with param as the last one */}
<Route path="/:type/:method/passcode-validation" element={<Passcode />} />
</Route>

View file

@ -1,34 +0,0 @@
import { SignInIdentifier } from '@logto/schemas';
import { UserFlow } from '@/types';
import {
verifyForgotPasswordEmailPasscode,
verifyForgotPasswordSmsPasscode,
} from './forgot-password';
import { verifyRegisterEmailPasscode, verifyRegisterSmsPasscode } from './register';
import { verifySignInEmailPasscode, verifySignInSmsPasscode } from './sign-in';
import { getVerifyPasscodeApi } from './utils';
describe('api', () => {
it('getVerifyPasscodeApi', () => {
expect(getVerifyPasscodeApi(UserFlow.register, SignInIdentifier.Sms)).toBe(
verifyRegisterSmsPasscode
);
expect(getVerifyPasscodeApi(UserFlow.register, SignInIdentifier.Email)).toBe(
verifyRegisterEmailPasscode
);
expect(getVerifyPasscodeApi(UserFlow.signIn, SignInIdentifier.Sms)).toBe(
verifySignInSmsPasscode
);
expect(getVerifyPasscodeApi(UserFlow.signIn, SignInIdentifier.Email)).toBe(
verifySignInEmailPasscode
);
expect(getVerifyPasscodeApi(UserFlow.forgotPassword, SignInIdentifier.Email)).toBe(
verifyForgotPasswordEmailPasscode
);
expect(getVerifyPasscodeApi(UserFlow.forgotPassword, SignInIdentifier.Sms)).toBe(
verifyForgotPasswordSmsPasscode
);
});
});

View file

@ -1,10 +1,12 @@
import { PasscodeType } from '@logto/schemas';
import ky from 'ky';
import {
continueWithPassword,
continueWithUsername,
continueWithEmail,
continueWithPhone,
continueApi,
sendContinueSetEmailPasscode,
sendContinueSetPhonePasscode,
verifyContinueSetEmailPasscode,
verifyContinueSetSmsPasscode,
} from './continue';
jest.mock('ky', () => ({
@ -30,8 +32,8 @@ describe('continue API', () => {
});
it('continue with password', async () => {
await continueWithPassword('password');
expect(ky.post).toBeCalledWith('/api/session/continue/password', {
await continueApi('password', 'password');
expect(ky.post).toBeCalledWith('/api/session/sign-in/continue/password', {
json: {
password: 'password',
},
@ -39,8 +41,8 @@ describe('continue API', () => {
});
it('continue with username', async () => {
await continueWithUsername('username');
expect(ky.post).toBeCalledWith('/api/session/continue/username', {
await continueApi('username', 'username');
expect(ky.post).toBeCalledWith('/api/session/sign-in/continue/username', {
json: {
username: 'username',
},
@ -48,9 +50,9 @@ describe('continue API', () => {
});
it('continue with email', async () => {
await continueWithEmail('email');
await continueApi('email', 'email');
expect(ky.post).toBeCalledWith('/api/session/continue/email', {
expect(ky.post).toBeCalledWith('/api/session/sign-in/continue/email', {
json: {
email: 'email',
},
@ -58,12 +60,58 @@ describe('continue API', () => {
});
it('continue with phone', async () => {
await continueWithPhone('phone');
await continueApi('phone', 'phone');
expect(ky.post).toBeCalledWith('/api/session/continue/sms', {
expect(ky.post).toBeCalledWith('/api/session/sign-in/continue/sms', {
json: {
phone: 'phone',
},
});
});
it('sendContinueSetEmailPasscode', async () => {
await sendContinueSetEmailPasscode('email');
expect(ky.post).toBeCalledWith('/api/session/passwordless/email/send', {
json: {
email: 'email',
flow: PasscodeType.Continue,
},
});
});
it('sendContinueSetSmsPasscode', async () => {
await sendContinueSetPhonePasscode('111111');
expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/send', {
json: {
phone: '111111',
flow: PasscodeType.Continue,
},
});
});
it('verifyContinueSetEmailPasscode', async () => {
await verifyContinueSetEmailPasscode('email', 'passcode');
expect(ky.post).toBeCalledWith('/api/session/passwordless/email/verify', {
json: {
email: 'email',
code: 'passcode',
flow: PasscodeType.Continue,
},
});
});
it('verifyContinueSetSmsPasscode', async () => {
await verifyContinueSetSmsPasscode('phone', 'passcode');
expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/verify', {
json: {
phone: 'phone',
code: 'passcode',
flow: PasscodeType.Continue,
},
});
});
});

View file

@ -1,3 +1,5 @@
import { PasscodeType } from '@logto/schemas';
import api from './api';
import { bindSocialAccount } from './social';
@ -5,13 +7,15 @@ type Response = {
redirectTo: string;
};
const continueApiPrefix = '/api/session/continue';
const passwordlessApiPrefix = '/api/session/passwordless';
const continueApiPrefix = '/api/session/sign-in/continue';
// Only bind with social after the sign-in bind password flow
export const continueWithPassword = async (password: string, socialToBind?: string) => {
type ContinueKey = 'password' | 'username' | 'email' | 'phone';
export const continueApi = async (key: ContinueKey, value: string, socialToBind?: string) => {
const result = await api
.post(`${continueApiPrefix}/password`, {
json: { password },
.post(`${continueApiPrefix}/${key === 'phone' ? 'sms' : key}`, {
json: { [key]: value },
})
.json<Response>();
@ -22,11 +26,48 @@ export const continueWithPassword = async (password: string, socialToBind?: stri
return result;
};
export const continueWithUsername = async (username: string) =>
api.post(`${continueApiPrefix}/username`, { json: { username } }).json<Response>();
export const sendContinueSetEmailPasscode = async (email: string) => {
await api
.post(`${passwordlessApiPrefix}/email/send`, {
json: {
email,
flow: PasscodeType.Continue,
},
})
.json();
export const continueWithEmail = async (email: string) =>
api.post(`${continueApiPrefix}/email`, { json: { email } }).json<Response>();
return { success: true };
};
export const continueWithPhone = async (phone: string) =>
api.post(`${continueApiPrefix}/sms`, { json: { phone } }).json<Response>();
export const sendContinueSetPhonePasscode = async (phone: string) => {
await api
.post(`${passwordlessApiPrefix}/sms/send`, {
json: {
phone,
flow: PasscodeType.Continue,
},
})
.json();
return { success: true };
};
export const verifyContinueSetEmailPasscode = async (email: string, code: string) => {
await api
.post(`${passwordlessApiPrefix}/email/verify`, {
json: { email, code, flow: PasscodeType.Continue },
})
.json();
return { success: true };
};
export const verifyContinueSetSmsPasscode = async (phone: string, code: string) => {
await api
.post(`${passwordlessApiPrefix}/sms/verify`, {
json: { phone, code, flow: PasscodeType.Continue },
})
.json();
return { success: true };
};

View file

@ -2,27 +2,15 @@ import { SignInIdentifier } from '@logto/schemas';
import { UserFlow } from '@/types';
import {
sendForgotPasswordEmailPasscode,
sendForgotPasswordSmsPasscode,
verifyForgotPasswordEmailPasscode,
verifyForgotPasswordSmsPasscode,
} from './forgot-password';
import {
verifyRegisterEmailPasscode,
verifyRegisterSmsPasscode,
sendRegisterEmailPasscode,
sendRegisterSmsPasscode,
} from './register';
import {
verifySignInEmailPasscode,
verifySignInSmsPasscode,
sendSignInEmailPasscode,
sendSignInSmsPasscode,
} from './sign-in';
import { sendContinueSetEmailPasscode, sendContinueSetPhonePasscode } from './continue';
import { sendForgotPasswordEmailPasscode, sendForgotPasswordSmsPasscode } from './forgot-password';
import { sendRegisterEmailPasscode, sendRegisterSmsPasscode } from './register';
import { sendSignInEmailPasscode, sendSignInSmsPasscode } from './sign-in';
export type PasscodeChannel = SignInIdentifier.Email | SignInIdentifier.Sms;
// TODO: @simeng-li merge in to one single api
export const getSendPasscodeApi = (
type: UserFlow,
method: PasscodeChannel
@ -47,36 +35,13 @@ export const getSendPasscodeApi = (
return sendRegisterEmailPasscode;
}
return sendRegisterSmsPasscode;
};
export const getVerifyPasscodeApi = (
type: UserFlow,
method: PasscodeChannel
): ((
_address: string,
code: string,
socialToBind?: string
) => Promise<{ redirectTo?: string; success?: boolean }>) => {
if (type === UserFlow.forgotPassword && method === SignInIdentifier.Email) {
return verifyForgotPasswordEmailPasscode;
}
if (type === UserFlow.forgotPassword && method === SignInIdentifier.Sms) {
return verifyForgotPasswordSmsPasscode;
}
if (type === UserFlow.signIn && method === SignInIdentifier.Email) {
return verifySignInEmailPasscode;
}
if (type === UserFlow.signIn && method === SignInIdentifier.Sms) {
return verifySignInSmsPasscode;
}
if (type === UserFlow.register && method === SignInIdentifier.Email) {
return verifyRegisterEmailPasscode;
}
return verifyRegisterSmsPasscode;
if (type === UserFlow.register && method === SignInIdentifier.Sms) {
return sendRegisterSmsPasscode;
}
if (type === UserFlow.continue && method === SignInIdentifier.Email) {
return sendContinueSetEmailPasscode;
}
return sendContinueSetPhonePasscode;
};

View file

@ -57,6 +57,10 @@
:global(body.desktop) {
.button {
font: var(--font-label-2);
}
.primary {
&:focus-visible {
outline: 3px solid var(--color-overlay-brand-focused);

View file

@ -11,6 +11,11 @@
top: 50%;
transform: translate(-50%, -50%);
outline: none;
border-radius: 16px;
&:focus-visible {
outline: none;
}
}
.container {

View file

@ -11,6 +11,11 @@
top: 50%;
transform: translate(0, -50%);
outline: none;
border-radius: var(--radius);
&:focus-visible {
outline: none;
}
}
.container {

View file

@ -47,6 +47,7 @@ body {
.placeHolder {
flex: 1;
min-height: _.unit(5);
}
:global(body.mobile) {
@ -84,5 +85,6 @@ body {
border-radius: 16px;
background: var(--color-bg-float);
box-shadow: var(--color-shadow-2);
overflow: auto;
}
}

View file

@ -16,7 +16,7 @@ export type Props = {
const AppContent = ({ children }: Props) => {
const theme = useTheme();
const { toast, platform, setToast, experienceSettings } = useContext(PageContext);
const { toast, platform, setToast } = useContext(PageContext);
// Prevent internal eventListener rebind
const hideToast = useCallback(() => {

View file

@ -0,0 +1,49 @@
import { fireEvent, waitFor, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { sendContinueSetEmailPasscode } from '@/apis/continue';
import EmailContinue from './EmailContinue';
const mockedNavigate = jest.fn();
jest.mock('@/apis/continue', () => ({
sendContinueSetEmailPasscode: jest.fn(() => ({ success: true })),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
describe('EmailContinue', () => {
const email = 'foo@logto.io';
test('register form submit', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailContinue />
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: email } });
}
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(sendContinueSetEmailPasscode).toBeCalledWith(email);
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/continue/email/passcode-validation', search: '' },
{ state: { email } }
);
});
});
});

View file

@ -0,0 +1,32 @@
import { SignInIdentifier } from '@logto/schemas';
import usePasswordlessSendCode from '@/hooks/use-passwordless-send-code';
import { UserFlow } from '@/types';
import EmailForm from './EmailForm';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
hasSwitch?: boolean;
};
const EmailContinue = (props: Props) => {
const { onSubmit, errorMessage, clearErrorMessage } = usePasswordlessSendCode(
UserFlow.continue,
SignInIdentifier.Email
);
return (
<EmailForm
onSubmit={onSubmit}
{...props}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
hasTerms={false}
/>
);
};
export default EmailContinue;

View file

@ -83,6 +83,7 @@ const EmailForm = ({
{...rest}
onClear={() => {
setFieldValue((state) => ({ ...state, email: '' }));
clearErrorMessage?.();
}}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}

View file

@ -1,3 +1,4 @@
export { default as EmailRegister } from './EmailRegister';
export { default as EmailSignIn } from './EmailSignIn';
export { default as EmailResetPassword } from './EmailResetPassword';
export { default as EmailContinue } from './EmailContinue';

View file

@ -13,6 +13,10 @@ jest.mock('@/apis/sign-in', () => ({ signInWithEmailPassword: jest.fn(async () =
jest.mock('react-device-detect', () => ({
isMobile: true,
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
describe('<EmailPassword>', () => {
afterEach(() => {

View file

@ -2,6 +2,11 @@ import { SignInIdentifier } from '@logto/schemas';
import { act, fireEvent, waitFor } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import {
verifyContinueSetEmailPasscode,
continueApi,
verifyContinueSetSmsPasscode,
} from '@/apis/continue';
import {
verifyForgotPasswordEmailPasscode,
verifyForgotPasswordSmsPasscode,
@ -42,6 +47,12 @@ jest.mock('@/apis/forgot-password', () => ({
verifyForgotPasswordSmsPasscode: jest.fn(),
}));
jest.mock('@/apis/continue', () => ({
verifyContinueSetEmailPasscode: jest.fn(),
verifyContinueSetSmsPasscode: jest.fn(),
continueApi: jest.fn(),
}));
describe('<PasscodeValidation />', () => {
const email = 'foo@logto.io';
const phone = '18573333333';
@ -234,36 +245,98 @@ describe('<PasscodeValidation />', () => {
expect(mockedNavigate).toBeCalledWith('/forgot-password/reset', { replace: true });
});
});
it('fire sms forgot-password validate passcode event', async () => {
(verifyForgotPasswordSmsPasscode as jest.Mock).mockImplementationOnce(() => ({
success: true,
}));
const { container } = renderWithPageContext(
<PasscodeValidation
type={UserFlow.forgotPassword}
method={SignInIdentifier.Sms}
target={phone}
/>
);
const inputs = container.querySelectorAll('input');
for (const input of inputs) {
act(() => {
fireEvent.input(input, { target: { value: '1' } });
});
}
await waitFor(() => {
expect(verifyForgotPasswordSmsPasscode).toBeCalledWith(phone, '111111');
});
await waitFor(() => {
expect(window.location.replace).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith('/forgot-password/reset', { replace: true });
});
});
});
it('fire Sms forgot-password validate passcode event', async () => {
(verifyForgotPasswordSmsPasscode as jest.Mock).mockImplementationOnce(() => ({
success: true,
}));
describe('continue flow', () => {
it('set email', async () => {
(verifyContinueSetEmailPasscode as jest.Mock).mockImplementationOnce(() => ({
success: true,
}));
(continueApi as jest.Mock).mockImplementationOnce(() => ({ redirectTo: '/redirect' }));
const { container } = renderWithPageContext(
<PasscodeValidation
type={UserFlow.forgotPassword}
method={SignInIdentifier.Sms}
target={phone}
/>
);
const { container } = renderWithPageContext(
<PasscodeValidation
type={UserFlow.continue}
method={SignInIdentifier.Email}
target={email}
/>
);
const inputs = container.querySelectorAll('input');
const inputs = container.querySelectorAll('input');
for (const input of inputs) {
act(() => {
fireEvent.input(input, { target: { value: '1' } });
for (const input of inputs) {
act(() => {
fireEvent.input(input, { target: { value: '1' } });
});
}
await waitFor(() => {
expect(verifyContinueSetEmailPasscode).toBeCalledWith(email, '111111');
});
}
await waitFor(() => {
expect(verifyForgotPasswordSmsPasscode).toBeCalledWith(phone, '111111');
await waitFor(() => {
expect(continueApi).toBeCalledWith('email', email, undefined);
expect(window.location.replace).toBeCalledWith('/redirect');
});
});
await waitFor(() => {
expect(window.location.replace).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith('/forgot-password/reset', { replace: true });
it('set Phone', async () => {
(verifyContinueSetSmsPasscode as jest.Mock).mockImplementationOnce(() => ({
success: true,
}));
(continueApi as jest.Mock).mockImplementationOnce(() => ({ redirectTo: '/redirect' }));
const { container } = renderWithPageContext(
<PasscodeValidation type={UserFlow.continue} method={SignInIdentifier.Sms} target={phone} />
);
const inputs = container.querySelectorAll('input');
for (const input of inputs) {
act(() => {
fireEvent.input(input, { target: { value: '1' } });
});
}
await waitFor(() => {
expect(verifyContinueSetSmsPasscode).toBeCalledWith(phone, '111111');
});
await waitFor(() => {
expect(continueApi).toBeCalledWith('phone', phone, undefined);
expect(window.location.replace).toBeCalledWith('/redirect');
});
});
});
});

View file

@ -0,0 +1,75 @@
import { SignInIdentifier } from '@logto/schemas';
import { useMemo, useCallback } from 'react';
import { verifyContinueSetEmailPasscode, continueApi } from '@/apis/continue';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
import { UserFlow, SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import useIdentifierErrorAlert from './use-identifier-error-alert';
import useSharedErrorHandler from './use-shared-error-handler';
const useContinueSetEmailPasscodeValidation = (email: string, errorCallback?: () => void) => {
const { sharedErrorHandlers, errorMessage, clearErrorMessage } = useSharedErrorHandler();
const requiredProfileErrorHandler = useRequiredProfileErrorHandler(true);
const identifierNotExistErrorHandler = useIdentifierErrorAlert(
UserFlow.continue,
SignInIdentifier.Email,
email
);
const verifyPasscodeErrorHandlers: ErrorHandlers = useMemo(
() => ({
...sharedErrorHandlers,
callback: errorCallback,
}),
[errorCallback, sharedErrorHandlers]
);
const { run: verifyPasscode } = useApi(
verifyContinueSetEmailPasscode,
verifyPasscodeErrorHandlers
);
const setEmailErrorHandlers: ErrorHandlers = useMemo(
() => ({
'user.email_not_exists': identifierNotExistErrorHandler,
...requiredProfileErrorHandler,
callback: errorCallback,
}),
[errorCallback, identifierNotExistErrorHandler, requiredProfileErrorHandler]
);
const { run: setEmail } = useApi(continueApi, setEmailErrorHandlers);
const onSubmit = useCallback(
async (code: string) => {
const verified = await verifyPasscode(email, code);
if (!verified) {
return;
}
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
const result = await setEmail('email', email, socialToBind);
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
},
[email, setEmail, verifyPasscode]
);
return {
errorMessage,
clearErrorMessage,
onSubmit,
};
};
export default useContinueSetEmailPasscodeValidation;

View file

@ -0,0 +1,72 @@
import { SignInIdentifier } from '@logto/schemas';
import { useMemo, useCallback } from 'react';
import { verifyContinueSetSmsPasscode, continueApi } from '@/apis/continue';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
import { UserFlow, SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import useIdentifierErrorAlert from './use-identifier-error-alert';
import useSharedErrorHandler from './use-shared-error-handler';
const useContinueSetSmsPasscodeValidation = (phone: string, errorCallback?: () => void) => {
const { sharedErrorHandlers, errorMessage, clearErrorMessage } = useSharedErrorHandler();
const requiredProfileErrorHandler = useRequiredProfileErrorHandler(true);
const identifierNotExistErrorHandler = useIdentifierErrorAlert(
UserFlow.continue,
SignInIdentifier.Sms,
phone
);
const verifyPasscodeErrorHandlers: ErrorHandlers = useMemo(
() => ({
...sharedErrorHandlers,
callback: errorCallback,
}),
[errorCallback, sharedErrorHandlers]
);
const { run: verifyPasscode } = useApi(verifyContinueSetSmsPasscode, verifyPasscodeErrorHandlers);
const setPhoneErrorHandlers: ErrorHandlers = useMemo(
() => ({
'user.phone_not_exists': identifierNotExistErrorHandler,
...requiredProfileErrorHandler,
callback: errorCallback,
}),
[errorCallback, identifierNotExistErrorHandler, requiredProfileErrorHandler]
);
const { run: setPhone } = useApi(continueApi, setPhoneErrorHandlers);
const onSubmit = useCallback(
async (code: string) => {
const verified = await verifyPasscode(phone, code);
if (!verified) {
return;
}
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
const result = await setPhone('phone', phone, socialToBind);
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
},
[phone, setPhone, verifyPasscode]
);
return {
errorMessage,
clearErrorMessage,
onSubmit,
};
};
export default useContinueSetSmsPasscodeValidation;

View file

@ -8,6 +8,7 @@ import { signInWithEmail } from '@/apis/sign-in';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
import { useSieMethods } from '@/hooks/use-sie';
import { UserFlow } from '@/types';
@ -30,6 +31,8 @@ const useRegisterWithEmailPasscodeValidation = (email: string, errorCallback?: (
email
);
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler(true);
const emailExistSignInErrorHandler = useCallback(async () => {
const [confirm] = await show({
confirmText: 'action.sign_in',
@ -59,12 +62,14 @@ const useRegisterWithEmailPasscodeValidation = (email: string, errorCallback?: (
? identifierExistErrorHandler
: emailExistSignInErrorHandler,
...sharedErrorHandlers,
...requiredProfileErrorHandlers,
callback: errorCallback,
}),
[
emailExistSignInErrorHandler,
errorCallback,
identifierExistErrorHandler,
requiredProfileErrorHandlers,
sharedErrorHandlers,
signInMode,
]

View file

@ -8,6 +8,7 @@ import { signInWithSms } from '@/apis/sign-in';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
import { useSieMethods } from '@/hooks/use-sie';
import { UserFlow } from '@/types';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
@ -30,6 +31,8 @@ const useRegisterWithSmsPasscodeValidation = (phone: string, errorCallback?: ()
formatPhoneNumberWithCountryCallingCode(phone)
);
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler(true);
const phoneExistSignInErrorHandler = useCallback(async () => {
const [confirm] = await show({
confirmText: 'action.sign_in',
@ -59,14 +62,16 @@ const useRegisterWithSmsPasscodeValidation = (phone: string, errorCallback?: ()
? identifierExistErrorHandler
: phoneExistSignInErrorHandler,
...sharedErrorHandlers,
...requiredProfileErrorHandlers,
callback: errorCallback,
}),
[
phoneExistSignInErrorHandler,
errorCallback,
identifierExistErrorHandler,
sharedErrorHandlers,
signInMode,
identifierExistErrorHandler,
phoneExistSignInErrorHandler,
sharedErrorHandlers,
requiredProfileErrorHandlers,
errorCallback,
]
);

View file

@ -8,6 +8,7 @@ import { verifySignInEmailPasscode } from '@/apis/sign-in';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
import { useSieMethods } from '@/hooks/use-sie';
import { UserFlow, SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
@ -33,6 +34,8 @@ const useSignInWithEmailPasscodeValidation = (email: string, errorCallback?: ()
email
);
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler(true);
const emailNotExistRegisterErrorHandler = useCallback(async () => {
const [confirm] = await show({
confirmText: 'action.create',
@ -63,12 +66,14 @@ const useSignInWithEmailPasscodeValidation = (email: string, errorCallback?: ()
? identifierNotExistErrorHandler
: emailNotExistRegisterErrorHandler,
...sharedErrorHandlers,
...requiredProfileErrorHandlers,
callback: errorCallback,
}),
[
emailNotExistRegisterErrorHandler,
errorCallback,
identifierNotExistErrorHandler,
requiredProfileErrorHandlers,
sharedErrorHandlers,
signInMode,
socialToBind,

View file

@ -8,6 +8,7 @@ import { verifySignInSmsPasscode } from '@/apis/sign-in';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
import { useSieMethods } from '@/hooks/use-sie';
import { UserFlow, SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
@ -33,6 +34,8 @@ const useSignInWithSmsPasscodeValidation = (phone: string, errorCallback?: () =>
phone
);
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler(true);
const phoneNotExistRegisterErrorHandler = useCallback(async () => {
const [confirm] = await show({
confirmText: 'action.create',
@ -63,15 +66,17 @@ const useSignInWithSmsPasscodeValidation = (phone: string, errorCallback?: () =>
? identifierNotExistErrorHandler
: phoneNotExistRegisterErrorHandler,
...sharedErrorHandlers,
...requiredProfileErrorHandlers,
callback: errorCallback,
}),
[
phoneNotExistRegisterErrorHandler,
errorCallback,
identifierNotExistErrorHandler,
sharedErrorHandlers,
signInMode,
socialToBind,
identifierNotExistErrorHandler,
phoneNotExistRegisterErrorHandler,
sharedErrorHandlers,
requiredProfileErrorHandlers,
errorCallback,
]
);

View file

@ -2,6 +2,8 @@ import { SignInIdentifier } from '@logto/schemas';
import { UserFlow } from '@/types';
import useContinueSetEmailPasscodeValidation from './use-continue-set-email-passcode-validation';
import useContinueSetSmsPasscodeValidation from './use-continue-set-sms-passcode-validation';
import useForgotPasswordEmailPasscodeValidation from './use-forgot-password-email-passcode-validation';
import useForgotPasswordSmsPasscodeValidation from './use-forgot-password-sms-passcode-validation';
import useRegisterWithEmailPasscodeValidation from './use-register-with-email-passcode-validation';
@ -27,9 +29,8 @@ export const getPasscodeValidationHook = (
? useForgotPasswordEmailPasscodeValidation
: useForgotPasswordSmsPasscodeValidation;
default:
// TODO: continue flow hook
return method === SignInIdentifier.Email
? useRegisterWithEmailPasscodeValidation
: useRegisterWithSmsPasscodeValidation;
? useContinueSetEmailPasscodeValidation
: useContinueSetSmsPasscodeValidation;
}
};

View file

@ -0,0 +1,57 @@
import { fireEvent, waitFor, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { sendContinueSetPhonePasscode } from '@/apis/continue';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
import SmsContinue from './SmsContinue';
const mockedNavigate = jest.fn();
// PhoneNum CountryCode detection
jest.mock('i18next', () => ({
language: 'en',
}));
jest.mock('@/apis/continue', () => ({
sendContinueSetPhonePasscode: jest.fn(() => ({ success: true })),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
describe('SmsContinue', () => {
const phone = '8573333333';
const defaultCountryCallingCode = getDefaultCountryCallingCode();
const fullPhoneNumber = `${defaultCountryCallingCode}${phone}`;
test('register form submit', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SmsContinue />
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phone } });
}
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(sendContinueSetPhonePasscode).toBeCalledWith(fullPhoneNumber);
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/continue/sms/passcode-validation', search: '' },
{ state: { phone: fullPhoneNumber } }
);
});
});
});

View file

@ -0,0 +1,32 @@
import { SignInIdentifier } from '@logto/schemas';
import usePasswordlessSendCode from '@/hooks/use-passwordless-send-code';
import { UserFlow } from '@/types';
import SmsForm from './PhoneForm';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
hasSwitch?: boolean;
};
const SmsContinue = (props: Props) => {
const { onSubmit, errorMessage, clearErrorMessage } = usePasswordlessSendCode(
UserFlow.continue,
SignInIdentifier.Sms
);
return (
<SmsForm
onSubmit={onSubmit}
{...props}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
hasTerms={false}
/>
);
};
export default SmsContinue;

View file

@ -1,3 +1,4 @@
export { default as SmsRegister } from './SmsRegister';
export { default as SmsSignIn } from './SmsSignIn';
export { default as SmsResetPassword } from './SmsResetPassword';
export { default as SmsContinue } from './SmsContinue';

View file

@ -18,6 +18,10 @@ jest.mock('react-device-detect', () => ({
jest.mock('i18next', () => ({
language: 'en',
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
describe('<PhonePassword>', () => {
afterEach(() => {

View file

@ -0,0 +1,43 @@
import { fireEvent, act, waitFor } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { continueApi } from '@/apis/continue';
import SetUsername from '.';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
jest.mock('@/apis/continue', () => ({
continueApi: jest.fn(async () => ({})),
}));
describe('<UsernameRegister />', () => {
test('default render', () => {
const { queryByText, container } = renderWithPageContext(<SetUsername />);
expect(container.querySelector('input[name="new-username"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
test('submit form properly', async () => {
const { getByText, container } = renderWithPageContext(<SetUsername />);
const submitButton = getByText('action.continue');
const usernameInput = container.querySelector('input[name="new-username"]');
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(continueApi).toBeCalledWith('username', 'username', undefined);
});
});
});

View file

@ -0,0 +1,23 @@
import UsernameForm from '../UsernameForm';
import useSetUsername from './use-set-username';
type Props = {
className?: string;
};
const SetUsername = ({ className }: Props) => {
const { errorMessage, clearErrorMessage, onSubmit } = useSetUsername();
return (
<UsernameForm
className={className}
hasTerms={false}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
submitText="action.continue"
onSubmit={onSubmit}
/>
);
};
export default SetUsername;

View file

@ -0,0 +1,50 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { continueApi } from '@/apis/continue';
import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-api';
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
const useSetUsername = () => {
const navigate = useNavigate();
const [errorMessage, setErrorMessage] = useState<string>();
const clearErrorMessage = useCallback(() => {
setErrorMessage('');
}, []);
const requiredProfileErrorHandler = useRequiredProfileErrorHandler();
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'user.username_exists_register': (error) => {
setErrorMessage(error.message);
},
...requiredProfileErrorHandler,
}),
[requiredProfileErrorHandler]
);
const { result, run: setUsername } = useApi(continueApi, errorHandlers);
const onSubmit = useCallback(
async (username: string) => {
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
await setUsername('username', username, socialToBind);
},
[setUsername]
);
useEffect(() => {
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [navigate, result]);
return { errorMessage, clearErrorMessage, onSubmit };
};
export default useSetUsername;

View file

@ -2,49 +2,66 @@ import { fireEvent, act, waitFor } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { checkUsername } from '@/apis/register';
import UsernameRegister from '.';
import UsernameForm from './UsernameForm';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
jest.mock('@/apis/register', () => ({
checkUsername: jest.fn(async () => ({})),
}));
const onSubmit = jest.fn();
const onClearErrorMessage = jest.fn();
describe('<UsernameRegister />', () => {
test('default render', () => {
const { queryByText, container } = renderWithPageContext(<UsernameRegister />);
test('default render without terms', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider>
<UsernameForm hasTerms={false} onSubmit={onSubmit} />
</SettingsProvider>
);
expect(container.querySelector('input[name="new-username"]')).not.toBeNull();
expect(queryByText('description.terms_of_use')).toBeNull();
expect(queryByText('action.create_account')).not.toBeNull();
});
test('render with terms settings enabled', () => {
const { queryByText } = renderWithPageContext(
<SettingsProvider>
<UsernameRegister />
<UsernameForm onSubmit={onSubmit} />
</SettingsProvider>
);
expect(queryByText('description.terms_of_use')).not.toBeNull();
});
test('render with error message', () => {
const { queryByText, getByText } = renderWithPageContext(
<SettingsProvider>
<UsernameForm
errorMessage="error_message"
clearErrorMessage={onClearErrorMessage}
onSubmit={onSubmit}
/>
</SettingsProvider>
);
expect(queryByText('error_message')).not.toBeNull();
const submitButton = getByText('action.create_account');
fireEvent.click(submitButton);
expect(onClearErrorMessage).toBeCalled();
});
test('username are required', () => {
const { queryByText, getByText } = renderWithPageContext(<UsernameRegister />);
const { queryByText, getByText } = renderWithPageContext(<UsernameForm onSubmit={onSubmit} />);
const submitButton = getByText('action.create_account');
fireEvent.click(submitButton);
expect(queryByText('username_required')).not.toBeNull();
expect(checkUsername).not.toBeCalled();
expect(onSubmit).not.toBeCalled();
});
test('username with initial numeric char should throw', () => {
const { queryByText, getByText, container } = renderWithPageContext(<UsernameRegister />);
const { queryByText, getByText, container } = renderWithPageContext(
<UsernameForm onSubmit={onSubmit} />
);
const submitButton = getByText('action.create_account');
const usernameInput = container.querySelector('input[name="new-username"]');
@ -57,7 +74,7 @@ describe('<UsernameRegister />', () => {
expect(queryByText('username_should_not_start_with_number')).not.toBeNull();
expect(checkUsername).not.toBeCalled();
expect(onSubmit).not.toBeCalled();
// Clear error
if (usernameInput) {
@ -68,7 +85,9 @@ describe('<UsernameRegister />', () => {
});
test('username with special character should throw', () => {
const { queryByText, getByText, container } = renderWithPageContext(<UsernameRegister />);
const { queryByText, getByText, container } = renderWithPageContext(
<UsernameForm onSubmit={onSubmit} />
);
const submitButton = getByText('action.create_account');
const usernameInput = container.querySelector('input[name="new-username"]');
@ -80,7 +99,7 @@ describe('<UsernameRegister />', () => {
expect(queryByText('username_valid_charset')).not.toBeNull();
expect(checkUsername).not.toBeCalled();
expect(onSubmit).not.toBeCalled();
// Clear error
if (usernameInput) {
@ -93,7 +112,7 @@ describe('<UsernameRegister />', () => {
test('submit form properly with terms settings enabled', async () => {
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<UsernameRegister />
<UsernameForm onSubmit={onSubmit} />
</SettingsProvider>
);
const submitButton = getByText('action.create_account');
@ -111,7 +130,7 @@ describe('<UsernameRegister />', () => {
});
await waitFor(() => {
expect(checkUsername).toBeCalledWith('username');
expect(onSubmit).toBeCalledWith('username');
});
});
});

View file

@ -1,24 +1,25 @@
import { SignInIdentifier } from '@logto/schemas';
import type { I18nKey } from '@logto/phrases-ui';
import classNames from 'classnames';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { checkUsername } from '@/apis/register';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import Input from '@/components/Input';
import TermsOfUse from '@/containers/TermsOfUse';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useForm from '@/hooks/use-form';
import useTerms from '@/hooks/use-terms';
import { UserFlow } from '@/types';
import { usernameValidation } from '@/utils/field-validations';
import * as styles from './index.module.scss';
type Props = {
className?: string;
hasTerms?: boolean;
onSubmit: (username: string) => Promise<void>;
errorMessage?: string;
clearErrorMessage?: () => void;
submitText?: I18nKey;
};
type FieldState = {
@ -29,57 +30,41 @@ const defaultState: FieldState = {
username: '',
};
const UsernameRegister = ({ className }: Props) => {
const UsernameForm = ({
className,
hasTerms = true,
onSubmit,
errorMessage,
submitText = 'action.create_account',
clearErrorMessage,
}: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const navigate = useNavigate();
const {
fieldValue,
setFieldValue,
setFieldErrors,
register: fieldRegister,
validateForm,
} = useForm(defaultState);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'user.username_exists_register': () => {
setFieldErrors((state) => ({
...state,
username: 'username_exists',
}));
},
}),
[setFieldErrors]
);
const { run: asyncCheckUsername } = useApi(checkUsername, errorHandlers);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
clearErrorMessage?.();
if (!validateForm()) {
return;
}
if (!(await termsValidation())) {
if (hasTerms && !(await termsValidation())) {
return;
}
const { username } = fieldValue;
// Use sync call for this api to make sure the username value being passed to the password set page stays the same
const result = await asyncCheckUsername(username);
if (result) {
navigate(`/${UserFlow.register}/${SignInIdentifier.Username}/password`, {
state: { username },
});
}
void onSubmit(fieldValue.username);
},
[validateForm, termsValidation, fieldValue, asyncCheckUsername, navigate]
[clearErrorMessage, validateForm, hasTerms, termsValidation, onSubmit, fieldValue.username]
);
return (
@ -93,14 +78,15 @@ const UsernameRegister = ({ className }: Props) => {
setFieldValue((state) => ({ ...state, username: '' }));
}}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
<TermsOfUse className={styles.terms} />
{hasTerms && <TermsOfUse className={styles.terms} />}
<Button title="action.create_account" onClick={async () => onSubmitHandler()} />
<Button title={submitText} onClick={async () => onSubmitHandler()} />
<input hidden type="submit" />
</form>
);
};
export default UsernameRegister;
export default UsernameForm;

View file

@ -0,0 +1,51 @@
import { fireEvent, act, waitFor } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { checkUsername } from '@/apis/register';
import UsernameRegister from '.';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
jest.mock('@/apis/register', () => ({
checkUsername: jest.fn(async () => ({})),
}));
describe('<UsernameRegister />', () => {
test('default render', () => {
const { queryByText, container } = renderWithPageContext(<UsernameRegister />);
expect(container.querySelector('input[name="new-username"]')).not.toBeNull();
expect(queryByText('action.create_account')).not.toBeNull();
});
test('submit form properly', async () => {
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<UsernameRegister />
</SettingsProvider>
);
const submitButton = getByText('action.create_account');
const usernameInput = container.querySelector('input[name="new-username"]');
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
const termsButton = getByText('description.agree_with_terms');
fireEvent.click(termsButton);
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(checkUsername).toBeCalledWith('username');
});
});
});

View file

@ -0,0 +1,21 @@
import UsernameForm from '../UsernameForm';
import useUsernameRegister from './use-username-register';
type Props = {
className?: string;
};
const UsernameRegister = ({ className }: Props) => {
const { errorMessage, clearErrorMessage, onSubmit } = useUsernameRegister();
return (
<UsernameForm
className={className}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
onSubmit={onSubmit}
/>
);
};
export default UsernameRegister;

View file

@ -0,0 +1,45 @@
import { SignInIdentifier } from '@logto/schemas';
import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { checkUsername } from '@/apis/register';
import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-api';
import { UserFlow } from '@/types';
const useUsernameRegister = () => {
const navigate = useNavigate();
const [errorMessage, setErrorMessage] = useState<string>();
const clearErrorMessage = useCallback(() => {
setErrorMessage('');
}, []);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'user.username_exists_register': (error) => {
setErrorMessage(error.message);
},
}),
[]
);
const { run: asyncCheckUsername } = useApi(checkUsername, errorHandlers);
const onSubmit = useCallback(
async (username: string) => {
const result = await asyncCheckUsername(username);
if (result) {
navigate(`/${UserFlow.register}/${SignInIdentifier.Username}/password`, {
state: { username },
});
}
},
[asyncCheckUsername, navigate]
);
return { errorMessage, clearErrorMessage, onSubmit };
};
export default useUsernameRegister;

View file

@ -11,4 +11,9 @@
.terms {
margin-bottom: _.unit(4);
}
.formErrors {
margin-top: _.unit(-2);
margin-bottom: _.unit(4);
}
}

View file

@ -0,0 +1,2 @@
export { default as UsernameRegister } from './UsernameRegister';
export { default as SetUsername } from './SetUsername';

View file

@ -14,6 +14,10 @@ jest.mock('@/apis/sign-in', () => ({ signInWithUsername: jest.fn(async () => 0)
jest.mock('react-device-detect', () => ({
isMobile: true,
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
describe('<UsernameSignIn>', () => {
afterEach(() => {

View file

@ -7,10 +7,20 @@ import { registerWithSocial, bindSocialRelatedUser } from '@/apis/social';
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 { result: registerResult, run: asyncRegisterWithSocial } = useApi(registerWithSocial);
const { result: bindUserResult, run: asyncBindSocialRelatedUser } = useApi(bindSocialRelatedUser);
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler();
const { result: registerResult, run: asyncRegisterWithSocial } = useApi(
registerWithSocial,
requiredProfileErrorHandlers
);
const { result: bindUserResult, run: asyncBindSocialRelatedUser } = useApi(
bindSocialRelatedUser,
requiredProfileErrorHandlers
);
const createAccountHandler = useCallback(
(connectorId: string) => {

View file

@ -11,6 +11,8 @@ import useApi from '@/hooks/use-api';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import useRequiredProfileErrorHandler from './use-required-profile-error-handler';
const apiMap = {
[SignInIdentifier.Username]: signInWithUsername,
[SignInIdentifier.Email]: signInWithEmailPassword,
@ -24,13 +26,16 @@ const usePasswordSignIn = (method: SignInIdentifier) => {
setErrorMessage('');
}, []);
const requiredProfileErrorHandler = useRequiredProfileErrorHandler();
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'session.invalid_credentials': (error) => {
setErrorMessage(error.message);
},
...requiredProfileErrorHandler,
}),
[setErrorMessage]
[requiredProfileErrorHandler]
);
const { result, run: asyncSignIn } = useApi(apiMap[method], errorHandlers);

View file

@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { UserFlow } from '@/types';
const useRequiredProfileErrorHandler = (replace?: boolean) => {
const navigate = useNavigate();
const requiredProfileErrorHandler = useMemo(
() => ({
'user.require_password': () => {
navigate(
{
pathname: `/${UserFlow.continue}/password`,
search: location.search,
},
{ replace }
);
},
'user.require_username': () => {
navigate(
{
pathname: `/${UserFlow.continue}/username`,
search: location.search,
},
{ replace }
);
},
'user.require_email': () => {
navigate(
{
pathname: `/${UserFlow.continue}/email`,
search: location.search,
},
{ replace }
);
},
'user.require_sms': () => {
navigate(
{
pathname: `/${UserFlow.continue}/sms`,
search: location.search,
},
{ replace }
);
},
'user.require_email_or_sms': () => {
navigate(
{
pathname: `/${UserFlow.continue}/email`,
search: location.search,
},
{ replace }
);
},
}),
[navigate, replace]
);
return requiredProfileErrorHandler;
};
export default useRequiredProfileErrorHandler;

View file

@ -9,7 +9,11 @@ export const useSieMethods = () => {
return {
signUpMethods: methods ?? [],
signUpSettings: { password, verify },
signInMethods: experienceSettings?.signIn.methods ?? [],
signInMethods:
experienceSettings?.signIn.methods.filter(
// Filter out empty settings
({ password, verificationCode }) => password || verificationCode
) ?? [],
socialConnectors: experienceSettings?.socialConnectors ?? [],
signInMode: experienceSettings?.signInMode,
forgotPassword: experienceSettings?.forgotPassword,

View file

@ -10,9 +10,11 @@ import { stateValidation } from '@/utils/social-connectors';
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';
const useSocialSignInListener = () => {
const { setToast, experienceSettings } = useContext(PageContext);
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler();
const { t } = useTranslation();
const parameters = useParams();
@ -35,8 +37,15 @@ const useSocialSignInListener = () => {
});
}
},
...requiredProfileErrorHandlers,
}),
[experienceSettings?.signInMode, navigate, parameters.connector, setToast]
[
experienceSettings?.signInMode,
navigate,
parameters.connector,
requiredProfileErrorHandlers,
setToast,
]
);
const { result, run: asyncSignInWithSocial } = useApi(

View file

@ -0,0 +1,57 @@
import { SignInIdentifier } from '@logto/schemas';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import SetEmail from '.';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
useLocation: () => ({ pathname: '' }),
}));
describe('SetEmail', () => {
it('render set email', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: { ...mockSignInExperienceSettings.signUp, methods: [SignInIdentifier.Email] },
}}
>
<SetEmail />
</SettingsProvider>
);
expect(queryByText('description.link_email')).not.toBeNull();
expect(queryByText('description.link_email_description')).not.toBeNull();
expect(container.querySelector('input[name="email"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
it('render set email with phone alterations', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: {
...mockSignInExperienceSettings.signUp,
methods: [SignInIdentifier.Email, SignInIdentifier.Sms],
},
}}
>
<SetEmail />
</SettingsProvider>
);
expect(queryByText('description.link_email_or_phone')).not.toBeNull();
expect(queryByText('description.link_email_or_phone_description')).not.toBeNull();
expect(container.querySelector('input[name="email"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
expect(queryByText('action.switch_to')).not.toBeNull();
});
});

View file

@ -0,0 +1,31 @@
import { SignInIdentifier } from '@logto/schemas';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import { EmailContinue } from '@/containers/EmailForm';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
const SetEmail = () => {
const { signUpMethods } = useSieMethods();
if (!signUpMethods.includes(SignInIdentifier.Email)) {
return <ErrorPage />;
}
const phoneSignUpAlteration = signUpMethods.includes(SignInIdentifier.Sms);
return (
<SecondaryPageWrapper
title={phoneSignUpAlteration ? 'description.link_email_or_phone' : 'description.link_email'}
description={
phoneSignUpAlteration
? 'description.link_email_or_phone_description'
: 'description.link_email_description'
}
>
<EmailContinue autoFocus hasSwitch={phoneSignUpAlteration} />
</SecondaryPageWrapper>
);
};
export default SetEmail;

View file

@ -0,0 +1,58 @@
import { act, waitFor, fireEvent } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { continueApi } from '@/apis/continue';
import SetPassword from '.';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
jest.mock('@/apis/continue', () => ({
continueApi: jest.fn(async () => ({ redirectTo: '/' })),
}));
describe('SetPassword', () => {
it('render set-password page properly', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider>
<SetPassword />
</SettingsProvider>
);
expect(container.querySelector('input[name="new-password"]')).not.toBeNull();
expect(container.querySelector('input[name="confirm-new-password"]')).not.toBeNull();
expect(queryByText('action.save_password')).not.toBeNull();
});
it('should submit properly', async () => {
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<SetPassword />
</SettingsProvider>
);
const submitButton = getByText('action.save_password');
const passwordInput = container.querySelector('input[name="new-password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
act(() => {
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: '123456' } });
}
if (confirmPasswordInput) {
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
}
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(continueApi).toBeCalledWith('password', '123456', undefined);
});
});
});

View file

@ -0,0 +1,25 @@
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import SetPasswordForm from '@/containers/SetPassword';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
import useSetPassword from './use-set-password';
const SetPassword = () => {
const { setPassword } = useSetPassword();
const { signUpSettings } = useSieMethods();
// Password not enabled for sign-up identifiers
if (!signUpSettings.password) {
return <ErrorPage />;
}
return (
<SecondaryPageWrapper title="description.set_password">
<SetPasswordForm autoFocus onSubmit={setPassword} />
</SecondaryPageWrapper>
);
};
export default SetPassword;

View file

@ -0,0 +1,50 @@
import { useMemo, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { continueApi } from '@/apis/continue';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
const useSetPassword = () => {
const navigate = useNavigate();
const { show } = useConfirmModal();
const requiredProfileErrorHandler = useRequiredProfileErrorHandler(true);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'user.password_exists': async (error) => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);
},
...requiredProfileErrorHandler,
}),
[navigate, requiredProfileErrorHandler, show]
);
const { result, run: asyncSetPassword } = useApi(continueApi, errorHandlers);
const setPassword = useCallback(
async (password: string) => {
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
await asyncSetPassword('password', password, socialToBind);
},
[asyncSetPassword]
);
useEffect(() => {
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [navigate, result]);
return {
setPassword,
};
};
export default useSetPassword;

View file

@ -0,0 +1,61 @@
import { SignInIdentifier } from '@logto/schemas';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import SetPhone from '.';
const mockedNavigate = jest.fn();
// PhoneNum CountryCode detection
jest.mock('i18next', () => ({
language: 'en',
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
useLocation: () => ({ pathname: '' }),
}));
describe('SetPhone', () => {
it('render set phone', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: { ...mockSignInExperienceSettings.signUp, methods: [SignInIdentifier.Sms] },
}}
>
<SetPhone />
</SettingsProvider>
);
expect(queryByText('description.link_phone')).not.toBeNull();
expect(queryByText('description.link_phone_description')).not.toBeNull();
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
it('render set phone with email alterations', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: {
...mockSignInExperienceSettings.signUp,
methods: [SignInIdentifier.Email, SignInIdentifier.Sms],
},
}}
>
<SetPhone />
</SettingsProvider>
);
expect(queryByText('description.link_email_or_phone')).not.toBeNull();
expect(queryByText('description.link_email_or_phone_description')).not.toBeNull();
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
expect(queryByText('action.switch_to')).not.toBeNull();
});
});

View file

@ -0,0 +1,31 @@
import { SignInIdentifier } from '@logto/schemas';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import { SmsContinue } from '@/containers/PhoneForm';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
const SetPhone = () => {
const { signUpMethods } = useSieMethods();
if (!signUpMethods.includes(SignInIdentifier.Sms)) {
return <ErrorPage />;
}
const emailSignUpAlteration = signUpMethods.includes(SignInIdentifier.Email);
return (
<SecondaryPageWrapper
title={emailSignUpAlteration ? 'description.link_email_or_phone' : 'description.link_phone'}
description={
emailSignUpAlteration
? 'description.link_email_or_phone_description'
: 'description.link_phone_description'
}
>
<SmsContinue autoFocus hasSwitch={emailSignUpAlteration} />
</SecondaryPageWrapper>
);
};
export default SetPhone;

View file

@ -0,0 +1,52 @@
import { act, waitFor, fireEvent } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { continueApi } from '@/apis/continue';
import SetUsername from '.';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
jest.mock('@/apis/continue', () => ({
continueApi: jest.fn(async () => ({ redirectTo: '/' })),
}));
describe('SetPassword', () => {
it('render set-password page properly', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider>
<SetUsername />
</SettingsProvider>
);
expect(container.querySelector('input[name="new-username"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
it('should submit properly', async () => {
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<SetUsername />
</SettingsProvider>
);
const submitButton = getByText('action.continue');
const usernameInput = container.querySelector('input[name="new-username"]');
act(() => {
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(continueApi).toBeCalledWith('username', 'username', undefined);
});
});
});

View file

@ -0,0 +1,25 @@
import { SignInIdentifier } from '@logto/schemas';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import { SetUsername as SetUsernameForm } from '@/containers/UsernameForm';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
const SetUsername = () => {
const { signUpMethods } = useSieMethods();
if (!signUpMethods.includes(SignInIdentifier.Username)) {
return <ErrorPage />;
}
return (
<SecondaryPageWrapper
title="description.enter_username"
description="description.enter_username_description"
>
<SetUsernameForm />
</SecondaryPageWrapper>
);
};
export default SetUsername;

View file

@ -0,0 +1,37 @@
import { SignInIdentifier } from '@logto/schemas';
import { useParams } from 'react-router-dom';
import ErrorPage from '@/pages/ErrorPage';
import SetEmail from './SetEmail';
import SetPassword from './SetPassword';
import SetPhone from './SetPhone';
import SetUsername from './SetUsername';
type Parameters = {
method?: string;
};
const Continue = () => {
const { method = '' } = useParams<Parameters>();
if (method === 'password') {
return <SetPassword />;
}
if (method === SignInIdentifier.Username) {
return <SetUsername />;
}
if (method === SignInIdentifier.Email) {
return <SetEmail />;
}
if (method === SignInIdentifier.Sms) {
return <SetPhone />;
}
return <ErrorPage />;
};
export default Continue;

View file

@ -3,7 +3,7 @@ import type { SignInIdentifier, ConnectorMetadata } from '@logto/schemas';
import { EmailRegister } from '@/containers/EmailForm';
import { SmsRegister } from '@/containers/PhoneForm';
import SocialSignIn from '@/containers/SocialSignIn';
import UsernameRegister from '@/containers/UsernameRegister';
import { UsernameRegister } from '@/containers/UsernameForm';
import * as styles from './index.module.scss';

View file

@ -25,6 +25,14 @@ export const userFlowGuard = s.union([
s.literal('sign-in'),
s.literal('register'),
s.literal('forgot-password'),
s.literal('continue'),
]);
export const continueMethodGuard = s.union([
s.literal('password'),
s.literal('username'),
s.literal(SignInIdentifier.Email),
s.literal(SignInIdentifier.Sms),
]);
export const usernameGuard = s.object({