0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

Merge pull request #2039 from logto-io/feature/decouple-passcode-verification

refactor(core,phrases,schemas): decouple passcode verification
This commit is contained in:
Darcy Ye 2022-10-20 11:17:48 +08:00 committed by GitHub
commit d38543122d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1796 additions and 857 deletions

View file

@ -1 +1 @@
export const forgotPasswordVerificationTimeout = 10 * 60; // 10 mins.
export const verificationTimeout = 10 * 60; // 10 mins.

View file

@ -1,4 +1,4 @@
import { User } from '@logto/schemas';
import { PasscodeType, User } from '@logto/schemas';
import dayjs from 'dayjs';
import { Provider } from 'oidc-provider';
@ -6,7 +6,6 @@ import { mockPasswordEncrypted, mockUserWithPassword } from '@/__mocks__';
import RequestError from '@/errors/RequestError';
import { createRequester } from '@/utils/test-utils';
import { forgotPasswordVerificationTimeout } from './consts';
import forgotPasswordRoutes, { forgotPasswordRoute } from './forgot-password';
const encryptUserPassword = jest.fn(async (password: string) => ({
@ -14,7 +13,7 @@ const encryptUserPassword = jest.fn(async (password: string) => ({
passwordEncryptionMethod: 'Argon2i',
}));
const findUserById = jest.fn(async (): Promise<User> => mockUserWithPassword);
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ userId: 'id' }));
jest.mock('@/lib/user', () => ({
...jest.requireActual('@/lib/user'),
@ -24,16 +23,16 @@ jest.mock('@/lib/user', () => ({
jest.mock('@/queries/user', () => ({
...jest.requireActual('@/queries/user'),
hasUserWithPhone: async (phone: string) => phone === '13000000000',
findUserByPhone: async () => ({ id: 'id' }),
findUserByPhone: async () => ({ userId: 'id' }),
hasUserWithEmail: async (email: string) => email === 'a@a.com',
findUserByEmail: async () => ({ id: 'id' }),
findUserByEmail: async () => ({ userId: 'id' }),
findUserById: async () => findUserById(),
updateUserById: async (...args: unknown[]) => updateUserById(...args),
}));
const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } }));
jest.mock('@/lib/passcode', () => ({
createPasscode: async () => ({ id: 'id' }),
createPasscode: async () => ({ userId: 'id' }),
sendPasscode: async () => sendPasscode(),
verifyPasscode: async (_a: unknown, _b: unknown, code: string) => {
if (code !== '1234') {
@ -75,108 +74,6 @@ describe('session -> forgotPasswordRoutes', () => {
],
});
describe('POST /session/forgot-password/sms/send-passcode', () => {
beforeAll(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
it('should call sendPasscode', async () => {
const response = await sessionRequest
.post(`${forgotPasswordRoute}/sms/send-passcode`)
.send({ phone: '13000000000' });
expect(response.statusCode).toEqual(204);
expect(sendPasscode).toHaveBeenCalled();
});
});
describe('POST /session/forgot-password/sms/verify-passcode', () => {
it('assign result and redirect', async () => {
const fakeTime = new Date();
jest.useFakeTimers().setSystemTime(fakeTime);
const response = await sessionRequest
.post(`${forgotPasswordRoute}/sms/verify-passcode`)
.send({ phone: '13000000000', code: '1234' });
expect(response.statusCode).toEqual(204);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
forgotPassword: {
userId: 'id',
expiresAt: dayjs(fakeTime)
.add(forgotPasswordVerificationTimeout, 'second')
.toISOString(),
},
})
);
jest.useRealTimers();
});
it('throw error if phone number does not exist', async () => {
const response = await sessionRequest
.post(`${forgotPasswordRoute}/sms/verify-passcode`)
.send({ phone: '13000000001', code: '1234' });
expect(response.statusCode).toEqual(422);
});
it('throw error if verifyPasscode failed', async () => {
const response = await sessionRequest
.post(`${forgotPasswordRoute}/sms/verify-passcode`)
.send({ phone: '13000000000', code: '1231' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/forgot-password/email/send-passcode', () => {
beforeAll(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
it('should call sendPasscode', async () => {
const response = await sessionRequest
.post(`${forgotPasswordRoute}/email/send-passcode`)
.send({ email: 'a@a.com' });
expect(response.statusCode).toEqual(204);
expect(sendPasscode).toHaveBeenCalled();
});
});
describe('POST /session/forgot-password/email/verify-passcode', () => {
it('assign result and redirect', async () => {
const fakeTime = new Date();
jest.useFakeTimers().setSystemTime(fakeTime);
const response = await sessionRequest
.post(`${forgotPasswordRoute}/email/verify-passcode`)
.send({ email: 'a@a.com', code: '1234' });
expect(response.statusCode).toEqual(204);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
forgotPassword: {
userId: 'id',
expiresAt: dayjs(fakeTime)
.add(forgotPasswordVerificationTimeout, 'second')
.toISOString(),
},
})
);
jest.useRealTimers();
});
it('throw error if email does not exist', async () => {
const response = await sessionRequest
.post(`${forgotPasswordRoute}/email/verify-passcode`)
.send({ email: 'b@a.com', code: '1234' });
expect(response.statusCode).toEqual(422);
});
it('throw error if verifyPasscode failed', async () => {
const response = await sessionRequest
.post(`${forgotPasswordRoute}/sms/verify-passcode`)
.send({ email: 'a@a.com', code: '1231' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/forgot-password/reset', () => {
afterEach(() => {
jest.clearAllMocks();
@ -184,7 +81,11 @@ describe('session -> forgotPasswordRoutes', () => {
it('assign result and redirect', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
forgotPassword: { userId: 'id', expiresAt: dayjs().add(1, 'day').toISOString() },
verification: {
userId: 'id',
expiresAt: dayjs().add(1, 'day').toISOString(),
flow: PasscodeType.ForgotPassword,
},
},
});
const response = await sessionRequest
@ -199,10 +100,13 @@ describe('session -> forgotPasswordRoutes', () => {
);
expect(response.statusCode).toEqual(204);
});
it('should throw when `accountId` is missing', async () => {
it('should throw when `id` is missing', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
forgotPassword: { expiresAt: dayjs().add(1, 'day').toISOString() },
verification: {
expiresAt: dayjs().add(1, 'day').toISOString(),
flow: PasscodeType.ForgotPassword,
},
},
});
const response = await sessionRequest
@ -211,10 +115,26 @@ describe('session -> forgotPasswordRoutes', () => {
expect(response).toHaveProperty('status', 404);
expect(updateUserById).toBeCalledTimes(0);
});
it('should throw when `forgotPassword.expiresAt` is not string', async () => {
it('should throw when flow is not `forgot-password`', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
forgotPassword: { userId: 'id', expiresAt: 0 },
verification: {
userId: 'id',
expiresAt: dayjs().add(1, 'day').toISOString(),
flow: PasscodeType.SignIn,
},
},
});
const response = await sessionRequest
.post(`${forgotPasswordRoute}/reset`)
.send({ password: mockPasswordEncrypted });
expect(response).toHaveProperty('status', 404);
expect(updateUserById).toBeCalledTimes(0);
});
it('should throw when `verification.expiresAt` is not string', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: { userId: 'id', expiresAt: 0, flow: PasscodeType.ForgotPassword },
},
});
const response = await sessionRequest
@ -226,7 +146,11 @@ describe('session -> forgotPasswordRoutes', () => {
it('should throw when `expiresAt` is not a valid date string', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
forgotPassword: { userId: 'id', expiresAt: 'invalid date string' },
verification: {
userId: 'id',
expiresAt: 'invalid date string',
flow: PasscodeType.ForgotPassword,
},
},
});
const response = await sessionRequest
@ -238,7 +162,11 @@ describe('session -> forgotPasswordRoutes', () => {
it('should throw when verification expires', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
forgotPassword: { userId: 'id', expiresAt: dayjs().subtract(1, 'day').toISOString() },
verification: {
userId: 'id',
expiresAt: dayjs().subtract(1, 'day').toISOString(),
flow: PasscodeType.ForgotPassword,
},
},
});
const response = await sessionRequest
@ -250,7 +178,11 @@ describe('session -> forgotPasswordRoutes', () => {
it('should throw when new password is the same as old one', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
forgotPassword: { userId: 'id', expiresAt: dayjs().add(1, 'day').toISOString() },
verification: {
userId: 'id',
expiresAt: dayjs().add(1, 'day').toISOString(),
flow: PasscodeType.ForgotPassword,
},
},
});
mockArgon2Verify.mockResolvedValueOnce(true);
@ -263,7 +195,11 @@ describe('session -> forgotPasswordRoutes', () => {
it('should redirect when there was no old password', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
forgotPassword: { userId: 'id', expiresAt: dayjs().add(1, 'day').toISOString() },
verification: {
userId: 'id',
expiresAt: dayjs().add(1, 'day').toISOString(),
flow: PasscodeType.ForgotPassword,
},
},
});
findUserById.mockResolvedValueOnce({

View file

@ -1,155 +1,49 @@
import { emailRegEx, passwordRegEx, phoneRegEx } from '@logto/core-kit';
import { PasscodeType } from '@logto/schemas';
import dayjs from 'dayjs';
import { passwordRegEx } from '@logto/core-kit';
import { argon2Verify } from 'hash-wasm';
import { Provider } from 'oidc-provider';
import { z } from 'zod';
import RequestError from '@/errors/RequestError';
import { createPasscode, sendPasscode, verifyPasscode } from '@/lib/passcode';
import { encryptUserPassword } from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
import {
findUserByEmail,
findUserById,
findUserByPhone,
hasUserWithEmail,
hasUserWithPhone,
updateUserById,
} from '@/queries/user';
import { findUserById, updateUserById } from '@/queries/user';
import assertThat from '@/utils/assert-that';
import { AnonymousRouter } from '../types';
import { forgotPasswordVerificationTimeout } from './consts';
import { getRoutePrefix } from './utils';
import { forgotPasswordSessionResultGuard } from './types';
import {
clearVerificationResult,
getRoutePrefix,
getVerificationStorageFromInteraction,
checkValidateExpiration,
} from './utils';
export const forgotPasswordRoute = getRoutePrefix('forgot-password');
const forgotPasswordVerificationGuard = z.object({
forgotPassword: z.object({ userId: z.string(), expiresAt: z.string() }),
});
export default function forgotPasswordRoutes<T extends AnonymousRouter>(
router: T,
provider: Provider
) {
router.post(
`${forgotPasswordRoute}/sms/send-passcode`,
koaGuard({ body: z.object({ phone: z.string().regex(phoneRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone } = ctx.guard.body;
const type = 'ForgotPasswordSmsSendPasscode';
ctx.log(type, { phone });
const passcode = await createPasscode(jti, PasscodeType.ForgotPassword, { phone });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
return next();
}
);
router.post(
`${forgotPasswordRoute}/sms/verify-passcode`,
koaGuard({ body: z.object({ phone: z.string().regex(phoneRegEx), code: z.string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone, code } = ctx.guard.body;
const type = 'ForgotPasswordSms';
ctx.log(type, { phone, code });
assertThat(
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 422 })
);
await verifyPasscode(jti, PasscodeType.ForgotPassword, code, { phone });
const { id } = await findUserByPhone(phone);
ctx.log(type, { userId: id });
await provider.interactionResult(ctx.req, ctx.res, {
forgotPassword: {
userId: id,
expiresAt: dayjs().add(forgotPasswordVerificationTimeout, 'second').toISOString(),
},
});
ctx.status = 204;
return next();
}
);
router.post(
`${forgotPasswordRoute}/email/send-passcode`,
koaGuard({ body: z.object({ email: z.string().regex(emailRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email } = ctx.guard.body;
const type = 'ForgotPasswordEmailSendPasscode';
ctx.log(type, { email });
const passcode = await createPasscode(jti, PasscodeType.ForgotPassword, { email });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
return next();
}
);
router.post(
`${forgotPasswordRoute}/email/verify-passcode`,
koaGuard({ body: z.object({ email: z.string().regex(emailRegEx), code: z.string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email, code } = ctx.guard.body;
const type = 'ForgotPasswordEmail';
ctx.log(type, { email, code });
assertThat(
await hasUserWithEmail(email),
new RequestError({ code: 'user.email_not_exists', status: 422 })
);
await verifyPasscode(jti, PasscodeType.ForgotPassword, code, { email });
const { id } = await findUserByEmail(email);
await provider.interactionResult(ctx.req, ctx.res, {
forgotPassword: {
userId: id,
expiresAt: dayjs().add(forgotPasswordVerificationTimeout, 'second').toISOString(),
},
});
ctx.status = 204;
return next();
}
);
router.post(
`${forgotPasswordRoute}/reset`,
koaGuard({ body: z.object({ password: z.string().regex(passwordRegEx) }) }),
async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const { password } = ctx.guard.body;
const forgotPasswordVerificationResult = forgotPasswordVerificationGuard.safeParse(result);
assertThat(
forgotPasswordVerificationResult.success,
new RequestError({ code: 'session.forgot_password_session_not_found', status: 404 })
const verificationStorage = await getVerificationStorageFromInteraction(
ctx,
provider,
forgotPasswordSessionResultGuard
);
const {
forgotPassword: { userId: id, expiresAt },
} = forgotPasswordVerificationResult.data;
const type = 'ForgotPasswordReset';
ctx.log(type, verificationStorage);
assertThat(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
new RequestError({ code: 'session.forgot_password_verification_expired', status: 401 })
);
const { userId, expiresAt } = verificationStorage;
const { passwordEncrypted: oldPasswordEncrypted } = await findUserById(id);
checkValidateExpiration(expiresAt);
const { passwordEncrypted: oldPasswordEncrypted } = await findUserById(userId);
assertThat(
!oldPasswordEncrypted ||
@ -159,10 +53,10 @@ export default function forgotPasswordRoutes<T extends AnonymousRouter>(
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
const type = 'ForgotPasswordReset';
ctx.log(type, { userId: id });
ctx.log(type, { userId });
await updateUserById(id, { passwordEncrypted, passwordEncryptionMethod });
await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod });
await clearVerificationResult(ctx, provider);
ctx.status = 204;
return next();

View file

@ -0,0 +1,149 @@
import { PasscodeType } from '@logto/schemas';
import { MiddlewareType } from 'koa';
import { Provider } from 'oidc-provider';
import RequestError from '@/errors/RequestError';
import { assignInteractionResults } from '@/lib/session';
import { generateUserId, insertUser } from '@/lib/user';
import { WithLogContext } from '@/middleware/koa-log';
import {
hasUserWithPhone,
hasUserWithEmail,
findUserByPhone,
findUserByEmail,
updateUserById,
} from '@/queries/user';
import assertThat from '@/utils/assert-that';
import { smsSessionResultGuard, emailSessionResultGuard } from '../types';
import {
getVerificationStorageFromInteraction,
getPasswordlessRelatedLogType,
checkValidateExpiration,
} from '../utils';
export const smsSignInAction = <StateT, ContextT extends WithLogContext, ResponseBodyT>(
provider: Provider
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
return async (ctx, next) => {
const verificationStorage = await getVerificationStorageFromInteraction(
ctx,
provider,
smsSessionResultGuard
);
const type = getPasswordlessRelatedLogType(PasscodeType.SignIn, 'sms');
ctx.log(type, verificationStorage);
const { phone, expiresAt } = verificationStorage;
checkValidateExpiration(expiresAt);
assertThat(
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 404 })
);
const { id } = await findUserByPhone(phone);
ctx.log(type, { userId: id });
await updateUserById(id, { lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
};
};
export const emailSignInAction = <StateT, ContextT extends WithLogContext, ResponseBodyT>(
provider: Provider
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
return async (ctx, next) => {
const verificationStorage = await getVerificationStorageFromInteraction(
ctx,
provider,
emailSessionResultGuard
);
const type = getPasswordlessRelatedLogType(PasscodeType.SignIn, 'email');
ctx.log(type, verificationStorage);
const { email, expiresAt } = verificationStorage;
checkValidateExpiration(expiresAt);
assertThat(
await hasUserWithEmail(email),
new RequestError({ code: 'user.email_not_exists', status: 404 })
);
const { id } = await findUserByEmail(email);
ctx.log(type, { userId: id });
await updateUserById(id, { lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
};
};
export const smsRegisterAction = <StateT, ContextT extends WithLogContext, ResponseBodyT>(
provider: Provider
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
return async (ctx, next) => {
const verificationStorage = await getVerificationStorageFromInteraction(
ctx,
provider,
smsSessionResultGuard
);
const type = getPasswordlessRelatedLogType(PasscodeType.Register, 'sms');
ctx.log(type, verificationStorage);
const { phone, expiresAt } = verificationStorage;
checkValidateExpiration(expiresAt);
assertThat(
!(await hasUserWithPhone(phone)),
new RequestError({ code: 'user.phone_exists_register', status: 422 })
);
const id = await generateUserId();
ctx.log(type, { userId: id });
await insertUser({ id, primaryPhone: phone, lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
};
};
export const emailRegisterAction = <StateT, ContextT extends WithLogContext, ResponseBodyT>(
provider: Provider
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
return async (ctx, next) => {
const verificationStorage = await getVerificationStorageFromInteraction(
ctx,
provider,
emailSessionResultGuard
);
const type = getPasswordlessRelatedLogType(PasscodeType.Register, 'email');
ctx.log(type, verificationStorage);
const { email, expiresAt } = verificationStorage;
checkValidateExpiration(expiresAt);
assertThat(
!(await hasUserWithEmail(email)),
new RequestError({ code: 'user.email_exists_register', status: 422 })
);
const id = await generateUserId();
ctx.log(type, { userId: id });
await insertUser({ id, primaryEmail: email, lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
};
};

View file

@ -1,10 +1,14 @@
import { User } from '@logto/schemas';
/* eslint-disable max-lines */
import { PasscodeType, User } from '@logto/schemas';
import dayjs from 'dayjs';
import { Provider } from 'oidc-provider';
import { mockUser } from '@/__mocks__';
import RequestError from '@/errors/RequestError';
import { createRequester } from '@/utils/test-utils';
import { verificationTimeout } from './consts';
import * as passwordlessActions from './middleware/passwordless-action';
import passwordlessRoutes, { registerRoute, signInRoute } from './passwordless';
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
@ -26,9 +30,15 @@ jest.mock('@/queries/user', () => ({
hasUserWithEmail: async (email: string) => email === 'a@a.com',
}));
const smsSignInActionSpy = jest.spyOn(passwordlessActions, 'smsSignInAction');
const emailSignInActionSpy = jest.spyOn(passwordlessActions, 'emailSignInAction');
const smsRegisterActionSpy = jest.spyOn(passwordlessActions, 'smsRegisterAction');
const emailRegisterActionSpy = jest.spyOn(passwordlessActions, 'emailRegisterAction');
const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } }));
const createPasscode = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
jest.mock('@/lib/passcode', () => ({
createPasscode: async () => ({ id: 'id' }),
createPasscode: async (..._args: unknown[]) => createPasscode(..._args),
sendPasscode: async () => sendPasscode(),
verifyPasscode: async (_a: unknown, _b: unknown, code: string) => {
if (code !== '1234') {
@ -65,255 +75,750 @@ describe('session -> passwordlessRoutes', () => {
],
});
describe('POST /session/sign-in/passwordless/sms/send-passcode', () => {
beforeAll(() => {
describe('POST /session/passwordless/sms/send', () => {
beforeEach(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
it('should call sendPasscode', async () => {
afterEach(() => {
jest.clearAllMocks();
jest.resetModules();
});
it('should call sendPasscode (with flow `sign-in`)', async () => {
const response = await sessionRequest
.post(`${signInRoute}/sms/send-passcode`)
.send({ phone: '13000000000' });
.post('/session/passwordless/sms/send')
.send({ phone: '13000000000', flow: PasscodeType.SignIn });
expect(response.statusCode).toEqual(204);
expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.SignIn, {
phone: '13000000000',
});
expect(sendPasscode).toHaveBeenCalled();
});
it('throw error if phone does not exist', async () => {
it('should call sendPasscode (with flow `register`)', async () => {
const response = await sessionRequest
.post(`${signInRoute}/sms/send-passcode`)
.send({ phone: '13000000001' });
expect(response.statusCode).toEqual(422);
.post('/session/passwordless/sms/send')
.send({ phone: '13000000000', flow: PasscodeType.Register });
expect(response.statusCode).toEqual(204);
expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.Register, {
phone: '13000000000',
});
expect(sendPasscode).toHaveBeenCalled();
});
});
describe('POST /session/sign-in/passwordless/sms/verify-passcode', () => {
it('assign result and redirect', async () => {
it('should call sendPasscode (with flow `forgot-password`)', async () => {
const response = await sessionRequest
.post(`${signInRoute}/sms/verify-passcode`)
.send({ phone: '13000000000', code: '1234' });
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'id' } }),
expect.anything()
);
.post('/session/passwordless/sms/send')
.send({ phone: '13000000000', flow: PasscodeType.ForgotPassword });
expect(response.statusCode).toEqual(204);
expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.ForgotPassword, {
phone: '13000000000',
});
expect(sendPasscode).toHaveBeenCalled();
});
it('throw error if phone does not exist', async () => {
it('throw when phone not given in input params', async () => {
const response = await sessionRequest
.post(`${signInRoute}/sms/verify-passcode`)
.send({ phone: '13000000001', code: '1234' });
expect(response.statusCode).toEqual(422);
});
it('throw error if verifyPasscode failed', async () => {
const response = await sessionRequest
.post(`${signInRoute}/sms/verify-passcode`)
.send({ phone: '13000000000', code: '1231' });
.post('/session/passwordless/sms/send')
.send({ flow: PasscodeType.Register });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/sign-in/passwordless/email/send-passcode', () => {
beforeAll(() => {
interactionDetails.mockResolvedValue({
describe('POST /session/passwordless/email/send', () => {
beforeEach(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
it('should call sendPasscode', async () => {
afterEach(() => {
jest.clearAllMocks();
jest.resetModules();
});
it('should call sendPasscode (with flow `sign-in`)', async () => {
const response = await sessionRequest
.post(`${signInRoute}/email/send-passcode`)
.send({ email: 'a@a.com' });
.post('/session/passwordless/email/send')
.send({ email: 'a@a.com', flow: PasscodeType.SignIn });
expect(response.statusCode).toEqual(204);
expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.SignIn, {
email: 'a@a.com',
});
expect(sendPasscode).toHaveBeenCalled();
});
it('throw error if email does not exist', async () => {
it('should call sendPasscode (with flow `register`)', async () => {
const response = await sessionRequest
.post(`${signInRoute}/email/send-passcode`)
.send({ email: 'b@a.com' });
expect(response.statusCode).toEqual(422);
.post('/session/passwordless/email/send')
.send({ email: 'a@a.com', flow: PasscodeType.Register });
expect(response.statusCode).toEqual(204);
expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.Register, {
email: 'a@a.com',
});
expect(sendPasscode).toHaveBeenCalled();
});
});
describe('POST /session/sign-in/passwordless/email/verify-passcode', () => {
it('assign result and redirect', async () => {
it('should call sendPasscode (with flow `forgot-password`)', async () => {
const response = await sessionRequest
.post(`${signInRoute}/email/verify-passcode`)
.send({ email: 'a@a.com', code: '1234' });
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'id' } }),
expect.anything()
);
.post('/session/passwordless/email/send')
.send({ email: 'a@a.com', flow: PasscodeType.ForgotPassword });
expect(response.statusCode).toEqual(204);
expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.ForgotPassword, {
email: 'a@a.com',
});
expect(sendPasscode).toHaveBeenCalled();
});
it('throw error if email does not exist', async () => {
it('throw when email not given in input params', async () => {
const response = await sessionRequest
.post(`${signInRoute}/email/send-passcode`)
.send({ email: 'b@a.com' });
expect(response.statusCode).toEqual(422);
});
it('throw error if verifyPasscode failed', async () => {
const response = await sessionRequest
.post(`${signInRoute}/email/verify-passcode`)
.send({ email: 'a@a.com', code: '1231' });
.post('/session/passwordless/email/send')
.send({ flow: PasscodeType.Register });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/register/passwordless/sms/send-passcode', () => {
beforeAll(() => {
describe('POST /session/passwordless/sms/verify', () => {
beforeEach(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
it('should call sendPasscode', async () => {
const response = await sessionRequest
.post(`${registerRoute}/sms/send-passcode`)
.send({ phone: '13000000001' });
expect(response.statusCode).toEqual(204);
expect(sendPasscode).toHaveBeenCalled();
afterEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
jest.resetModules();
});
it('throw error if phone not valid (charactors other than digits)', async () => {
const response = await sessionRequest
.post(`${registerRoute}/sms/send-passcode`)
.send({ phone: '1300000000a' });
expect(response.statusCode).toEqual(400);
});
it('should call interactionResult (with flow `sign-in`)', async () => {
const fakeTime = new Date();
jest.useFakeTimers().setSystemTime(fakeTime);
it('throw error if phone not valid (without digits)', async () => {
const response = await sessionRequest
.post(`${registerRoute}/sms/send-passcode`)
.send({ phone: 'abcdefg' });
expect(response.statusCode).toEqual(400);
});
await sessionRequest
.post('/session/passwordless/sms/verify')
.send({ phone: '13000000000', code: '1234', flow: PasscodeType.SignIn });
it('throw error if phone exists', async () => {
const response = await sessionRequest
.post(`${registerRoute}/sms/send-passcode`)
.send({ phone: '13000000000' });
expect(response.statusCode).toEqual(422);
});
});
describe('POST /session/register/passwordless/sms/verify-passcode', () => {
it('assign result and redirect', async () => {
const response = await sessionRequest
.post(`${registerRoute}/sms/verify-passcode`)
.send({ phone: '13000000001', code: '1234' });
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(insertUser).toHaveBeenCalledWith(
expect.objectContaining({ id: 'user1', primaryPhone: '13000000001' })
);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'user1' } }),
expect.anything()
expect.objectContaining({
verification: {
flow: PasscodeType.SignIn,
phone: '13000000000',
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
},
})
);
// Should call sign-in with sms properly
expect(smsSignInActionSpy).toBeCalled();
});
it('should call interactionResult (with flow `register`)', async () => {
const fakeTime = new Date();
jest.useFakeTimers().setSystemTime(fakeTime);
await sessionRequest
.post('/session/passwordless/sms/verify')
.send({ phone: '13000000000', code: '1234', flow: PasscodeType.Register });
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
verification: {
flow: PasscodeType.Register,
phone: '13000000000',
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
},
})
);
expect(smsRegisterActionSpy).toBeCalled();
});
it('should call interactionResult (with flow `forgot-password`)', async () => {
const fakeTime = new Date();
jest.useFakeTimers().setSystemTime(fakeTime);
const response = await sessionRequest
.post('/session/passwordless/sms/verify')
.send({ phone: '13000000000', code: '1234', flow: PasscodeType.ForgotPassword });
expect(response.statusCode).toEqual(204);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
verification: {
userId: 'id',
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
flow: PasscodeType.ForgotPassword,
},
})
);
});
it('throw error if phone is invalid (characters other than digits)', async () => {
it('throw 404 (with flow `forgot-password`)', async () => {
const response = await sessionRequest
.post(`${registerRoute}/sms/verify-passcode`)
.send({ phone: '1300000000a', code: '1234' });
expect(response.statusCode).toEqual(400);
.post('/session/passwordless/sms/verify')
.send({ phone: '13000000001', code: '1234', flow: PasscodeType.ForgotPassword });
expect(response.statusCode).toEqual(404);
expect(interactionResult).toHaveBeenCalledTimes(0);
});
it('throw error if phone not valid (without digits)', async () => {
it('throw when code is wrong', async () => {
const response = await sessionRequest
.post(`${registerRoute}/sms/verify-passcode`)
.send({ phone: 'abcdefg', code: '1234' });
expect(response.statusCode).toEqual(400);
});
it('throw error if phone exists', async () => {
const response = await sessionRequest
.post(`${registerRoute}/sms/verify-passcode`)
.send({ phone: '13000000000', code: '1234' });
expect(response.statusCode).toEqual(422);
});
it('throw error if verifyPasscode failed', async () => {
const response = await sessionRequest
.post(`${registerRoute}/sms/verify-passcode`)
.send({ phone: '13000000001', code: '1231' });
.post('/session/passwordless/sms/verify')
.send({ phone: '13000000000', code: '1231', flow: PasscodeType.SignIn });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/register/passwordless/email/send-passcode', () => {
beforeAll(() => {
describe('POST /session/passwordless/email/verify', () => {
beforeEach(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
it('should call sendPasscode', async () => {
const response = await sessionRequest
.post(`${registerRoute}/email/send-passcode`)
.send({ email: 'b@a.com' });
expect(response.statusCode).toEqual(204);
expect(sendPasscode).toHaveBeenCalled();
afterEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
jest.resetModules();
});
it('throw error if email not valid', async () => {
const response = await sessionRequest
.post(`${registerRoute}/email/send-passcode`)
.send({ email: 'aaa.com' });
expect(response.statusCode).toEqual(400);
});
it('should call interactionResult (with flow `sign-in`)', async () => {
const fakeTime = new Date();
jest.useFakeTimers().setSystemTime(fakeTime);
it('throw error if email exists', async () => {
const response = await sessionRequest
.post(`${registerRoute}/email/send-passcode`)
.send({ email: 'a@a.com' });
expect(response.statusCode).toEqual(422);
});
});
await sessionRequest
.post('/session/passwordless/email/verify')
.send({ email: 'a@a.com', code: '1234', flow: PasscodeType.SignIn });
describe('POST /session/register/passwordless/email/verify-passcode', () => {
it('assign result and redirect', async () => {
const response = await sessionRequest
.post(`${registerRoute}/email/verify-passcode`)
.send({ email: 'b@a.com', code: '1234' });
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(insertUser).toHaveBeenCalledWith(
expect.objectContaining({ id: 'user1', primaryEmail: 'b@a.com' })
);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'user1' } }),
expect.objectContaining({
verification: {
flow: PasscodeType.SignIn,
email: 'a@a.com',
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
},
})
);
expect(emailSignInActionSpy).toBeCalled();
});
it('should call interactionResult (with flow `register`)', async () => {
const fakeTime = new Date();
jest.useFakeTimers().setSystemTime(fakeTime);
await sessionRequest
.post('/session/passwordless/email/verify')
.send({ email: 'a@a.com', code: '1234', flow: PasscodeType.Register });
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
verification: {
flow: PasscodeType.Register,
email: 'a@a.com',
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
},
})
);
expect(emailRegisterActionSpy).toBeCalled();
});
it('should call interactionResult (with flow `forgot-password`)', async () => {
const fakeTime = new Date();
jest.useFakeTimers().setSystemTime(fakeTime);
const response = await sessionRequest
.post('/session/passwordless/email/verify')
.send({ email: 'a@a.com', code: '1234', flow: PasscodeType.ForgotPassword });
expect(response.statusCode).toEqual(204);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
verification: {
userId: 'id',
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
flow: PasscodeType.ForgotPassword,
},
})
);
});
it('throw 404 (with flow `forgot-password`)', async () => {
const fakeTime = new Date();
jest.useFakeTimers().setSystemTime(fakeTime);
const response = await sessionRequest
.post('/session/passwordless/email/verify')
.send({ email: 'b@a.com', code: '1234', flow: PasscodeType.ForgotPassword });
expect(response.statusCode).toEqual(404);
expect(interactionResult).toHaveBeenCalledTimes(0);
});
it('throw when code is wrong', async () => {
const response = await sessionRequest
.post('/session/passwordless/email/verify')
.send({ email: 'a@a.com', code: '1231', flow: PasscodeType.SignIn });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/sign-in/passwordless/sms', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('should call interactionResult (with flow `sign-in`)', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
phone: '13000000000',
flow: PasscodeType.SignIn,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${signInRoute}/sms`);
expect(response.statusCode).toEqual(200);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
login: { accountId: 'id' },
}),
expect.anything()
);
});
it('throw error if email not valid', async () => {
const response = await sessionRequest
.post(`${signInRoute}/email/send-passcode`)
.send({ email: 'aaa.com' });
expect(response.statusCode).toEqual(400);
it('should call interactionResult (with flow `register`)', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
phone: '13000000000',
flow: PasscodeType.Register,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${signInRoute}/sms`);
expect(response.statusCode).toEqual(200);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
login: { accountId: 'id' },
}),
expect.anything()
);
});
it('throw error if email exist', async () => {
const response = await sessionRequest
.post(`${signInRoute}/email/send-passcode`)
.send({ email: 'b@a.com' });
it('throw when verification session invalid', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
phone: '13000000000',
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${signInRoute}/sms`);
expect(response.statusCode).toEqual(404);
});
it('throw when flow is not `sign-in` and `register`', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
phone: '13000000000',
flow: PasscodeType.ForgotPassword,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${signInRoute}/sms`);
expect(response.statusCode).toEqual(404);
});
it('throw when expiresAt is not valid ISO date string', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
phone: '13000000000',
flow: PasscodeType.SignIn,
expiresAt: 'invalid date string',
},
},
});
const response = await sessionRequest.post(`${signInRoute}/sms`);
expect(response.statusCode).toEqual(401);
});
it('throw when validation expired', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
phone: '13000000000',
flow: PasscodeType.SignIn,
expiresAt: dayjs().subtract(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${signInRoute}/sms`);
expect(response.statusCode).toEqual(401);
});
it('throw when phone not exist', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
email: 'XX@foo',
flow: PasscodeType.SignIn,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${signInRoute}/sms`);
expect(response.statusCode).toEqual(404);
});
it("throw when phone not exist as user's primaryPhone", async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
phone: '13000000001',
flow: PasscodeType.SignIn,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${signInRoute}/sms`);
expect(response.statusCode).toEqual(404);
});
});
describe('POST /session/sign-in/passwordless/email', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('should call interactionResult (with flow `sign-in`)', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
email: 'a@a.com',
flow: PasscodeType.SignIn,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${signInRoute}/email`);
expect(response.statusCode).toEqual(200);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
login: { accountId: 'id' },
}),
expect.anything()
);
});
it('should call interactionResult (with flow `register`)', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
email: 'a@a.com',
flow: PasscodeType.Register,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${signInRoute}/email`);
expect(response.statusCode).toEqual(200);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
login: { accountId: 'id' },
}),
expect.anything()
);
});
it('throw when verification session invalid', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
email: 'a@a.com',
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${signInRoute}/email`);
expect(response.statusCode).toEqual(404);
});
it('throw when flow is not `sign-in` and `register`', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
email: 'a@a.com',
flow: PasscodeType.ForgotPassword,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${signInRoute}/email`);
expect(response.statusCode).toEqual(404);
});
it('throw when email not exist', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
flow: PasscodeType.SignIn,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${signInRoute}/email`);
expect(response.statusCode).toEqual(404);
});
it("throw when email not exist as user's primaryEmail", async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
email: 'b@a.com',
flow: PasscodeType.SignIn,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${signInRoute}/email`);
expect(response.statusCode).toEqual(404);
});
});
describe('POST /session/register/passwordless/sms', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('should call interactionResult (with flow `register`)', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
phone: '13000000001',
flow: PasscodeType.Register,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${registerRoute}/sms`);
expect(response.statusCode).toEqual(200);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
login: { accountId: 'user1' },
}),
expect.anything()
);
});
it('should call interactionResult (with flow `sign-in`)', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
phone: '13000000001',
flow: PasscodeType.SignIn,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${registerRoute}/sms`);
expect(response.statusCode).toEqual(200);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
login: { accountId: 'user1' },
}),
expect.anything()
);
});
it('throw when verification session invalid', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
phone: '13000000001',
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${registerRoute}/sms`);
expect(response.statusCode).toEqual(404);
});
it('throw when flow is not `register` and `sign-in`', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
phone: '13000000001',
flow: PasscodeType.ForgotPassword,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${registerRoute}/sms`);
expect(response.statusCode).toEqual(404);
});
it('throw when phone not exist', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
flow: PasscodeType.Register,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${registerRoute}/sms`);
expect(response.statusCode).toEqual(404);
});
it("throw when phone already exist as user's primaryPhone", async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
phone: '13000000000',
flow: PasscodeType.Register,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${registerRoute}/sms`);
expect(response.statusCode).toEqual(422);
});
});
it('throw error if verifyPasscode failed', async () => {
const response = await sessionRequest
.post(`${signInRoute}/email/verify-passcode`)
.send({ email: 'a@a.com', code: '1231' });
expect(response.statusCode).toEqual(400);
describe('POST /session/register/passwordless/email', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('should call interactionResult (with flow `register`)', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
email: 'b@a.com',
flow: PasscodeType.Register,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${registerRoute}/email`);
expect(response.statusCode).toEqual(200);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
login: { accountId: 'user1' },
}),
expect.anything()
);
});
it('should call interactionResult (with flow `sign-in`)', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
email: 'b@a.com',
flow: PasscodeType.SignIn,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${registerRoute}/email`);
expect(response.statusCode).toEqual(200);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
login: { accountId: 'user1' },
}),
expect.anything()
);
});
it('throw when verification session invalid', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
email: 'b@a.com',
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${registerRoute}/email`);
expect(response.statusCode).toEqual(404);
});
it('throw when flow is not `register` and `sign-in`', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
email: 'b@a.com',
flow: PasscodeType.ForgotPassword,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${registerRoute}/email`);
expect(response.statusCode).toEqual(404);
});
it('throw when email not exist', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
flow: PasscodeType.Register,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${registerRoute}/email`);
expect(response.statusCode).toEqual(404);
});
it("throw when email already exist as user's primaryEmail", async () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
email: 'a@a.com',
flow: PasscodeType.Register,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
const response = await sessionRequest.post(`${registerRoute}/email`);
expect(response.statusCode).toEqual(422);
});
});
});
/* eslint-enable max-lines */

View file

@ -5,20 +5,24 @@ import { object, string } from 'zod';
import RequestError from '@/errors/RequestError';
import { createPasscode, sendPasscode, verifyPasscode } from '@/lib/passcode';
import { assignInteractionResults } from '@/lib/session';
import { generateUserId, insertUser } from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
import {
updateUserById,
hasUserWithEmail,
hasUserWithPhone,
findUserByEmail,
findUserByPhone,
hasUserWithEmail,
hasUserWithPhone,
} from '@/queries/user';
import { passcodeTypeGuard } from '@/routes/session/types';
import assertThat from '@/utils/assert-that';
import { AnonymousRouter } from '../types';
import { getRoutePrefix } from './utils';
import {
smsSignInAction,
emailSignInAction,
smsRegisterAction,
emailRegisterAction,
} from './middleware/passwordless-action';
import { assignVerificationResult, getPasswordlessRelatedLogType, getRoutePrefix } from './utils';
export const registerRoute = getRoutePrefix('register', 'passwordless');
export const signInRoute = getRoutePrefix('sign-in', 'passwordless');
@ -28,20 +32,23 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
provider: Provider
) {
router.post(
`${signInRoute}/sms/send-passcode`,
koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }),
'/session/passwordless/sms/send',
koaGuard({
body: object({
phone: string().regex(phoneRegEx),
flow: passcodeTypeGuard,
}),
}),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone } = ctx.guard.body;
const type = 'SignInSmsSendPasscode';
const {
body: { phone, flow },
} = ctx.guard;
const type = getPasswordlessRelatedLogType(flow, 'sms', 'send');
ctx.log(type, { phone });
assertThat(
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 422 })
);
const passcode = await createPasscode(jti, PasscodeType.SignIn, { phone });
const passcode = await createPasscode(jti, flow, { phone });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
@ -51,45 +58,23 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
);
router.post(
`${signInRoute}/sms/verify-passcode`,
koaGuard({ body: object({ phone: string().regex(phoneRegEx), code: string() }) }),
'/session/passwordless/email/send',
koaGuard({
body: object({
email: string().regex(emailRegEx),
flow: passcodeTypeGuard,
}),
}),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone, code } = ctx.guard.body;
const type = 'SignInSms';
ctx.log(type, { phone, code });
const {
body: { email, flow },
} = ctx.guard;
assertThat(
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 422 })
);
await verifyPasscode(jti, PasscodeType.SignIn, code, { phone });
const { id } = await findUserByPhone(phone);
ctx.log(type, { userId: id });
await updateUserById(id, { lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
return next();
}
);
router.post(
`${signInRoute}/email/send-passcode`,
koaGuard({ body: object({ email: string().regex(emailRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email } = ctx.guard.body;
const type = 'SignInEmailSendPasscode';
const type = getPasswordlessRelatedLogType(flow, 'email', 'send');
ctx.log(type, { email });
assertThat(
await hasUserWithEmail(email),
new RequestError({ code: 'user.email_not_exists', status: 422 })
);
const passcode = await createPasscode(jti, PasscodeType.SignIn, { email });
const passcode = await createPasscode(jti, flow, { email });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
@ -99,123 +84,98 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
);
router.post(
`${signInRoute}/email/verify-passcode`,
koaGuard({ body: object({ email: string().regex(emailRegEx), code: string() }) }),
'/session/passwordless/sms/verify',
koaGuard({
body: object({
phone: string().regex(phoneRegEx),
code: string(),
flow: passcodeTypeGuard,
}),
}),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email, code } = ctx.guard.body;
const type = 'SignInEmail';
ctx.log(type, { email, code });
assertThat(
await hasUserWithEmail(email),
new RequestError({ code: 'user.email_not_exists', status: 422 })
);
const {
body: { phone, code, flow },
} = ctx.guard;
await verifyPasscode(jti, PasscodeType.SignIn, code, { email });
const { id } = await findUserByEmail(email);
ctx.log(type, { userId: id });
await updateUserById(id, { lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
return next();
}
);
router.post(
`${registerRoute}/sms/send-passcode`,
koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone } = ctx.guard.body;
const type = 'RegisterSmsSendPasscode';
const type = getPasswordlessRelatedLogType(flow, 'sms', 'verify');
ctx.log(type, { phone });
assertThat(
!(await hasUserWithPhone(phone)),
new RequestError({ code: 'user.phone_exists_register', status: 422 })
);
await verifyPasscode(jti, flow, code, { phone });
const passcode = await createPasscode(jti, PasscodeType.Register, { phone });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
if (flow === PasscodeType.ForgotPassword) {
assertThat(
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 404 })
);
return next();
const { id } = await findUserByPhone(phone);
await assignVerificationResult(ctx, provider, { flow, userId: id });
ctx.status = 204;
return next();
}
await assignVerificationResult(ctx, provider, { flow, phone });
if (flow === PasscodeType.SignIn) {
return smsSignInAction(provider)(ctx, next);
}
return smsRegisterAction(provider)(ctx, next);
}
);
router.post(
`${registerRoute}/sms/verify-passcode`,
koaGuard({ body: object({ phone: string().regex(phoneRegEx), code: string() }) }),
'/session/passwordless/email/verify',
koaGuard({
body: object({
email: string().regex(emailRegEx),
code: string(),
flow: passcodeTypeGuard,
}),
}),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone, code } = ctx.guard.body;
const type = 'RegisterSms';
ctx.log(type, { phone, code });
const {
body: { email, code, flow },
} = ctx.guard;
assertThat(
!(await hasUserWithPhone(phone)),
new RequestError({ code: 'user.phone_exists_register', status: 422 })
);
await verifyPasscode(jti, PasscodeType.Register, code, { phone });
const id = await generateUserId();
ctx.log(type, { userId: id });
await insertUser({ id, primaryPhone: phone, lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
);
router.post(
`${registerRoute}/email/send-passcode`,
koaGuard({ body: object({ email: string().regex(emailRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email } = ctx.guard.body;
const type = 'RegisterEmailSendPasscode';
const type = getPasswordlessRelatedLogType(flow, 'email', 'verify');
ctx.log(type, { email });
assertThat(
!(await hasUserWithEmail(email)),
new RequestError({ code: 'user.email_exists_register', status: 422 })
);
await verifyPasscode(jti, flow, code, { email });
const passcode = await createPasscode(jti, PasscodeType.Register, { email });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
if (flow === PasscodeType.ForgotPassword) {
assertThat(
await hasUserWithEmail(email),
new RequestError({ code: 'user.email_not_exists', status: 404 })
);
return next();
const { id } = await findUserByEmail(email);
await assignVerificationResult(ctx, provider, { flow, userId: id });
ctx.status = 204;
return next();
}
await assignVerificationResult(ctx, provider, { flow, email });
if (flow === PasscodeType.SignIn) {
return emailSignInAction(provider)(ctx, next);
}
return emailRegisterAction(provider)(ctx, next);
}
);
router.post(
`${registerRoute}/email/verify-passcode`,
koaGuard({ body: object({ email: string().regex(emailRegEx), code: string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email, code } = ctx.guard.body;
const type = 'RegisterEmail';
ctx.log(type, { email, code });
router.post(`${signInRoute}/sms`, smsSignInAction(provider));
assertThat(
!(await hasUserWithEmail(email)),
new RequestError({ code: 'user.email_exists_register', status: 422 })
);
router.post(`${signInRoute}/email`, emailSignInAction(provider));
await verifyPasscode(jti, PasscodeType.Register, code, { email });
const id = await generateUserId();
ctx.log(type, { userId: id });
router.post(`${registerRoute}/sms`, smsRegisterAction(provider));
await insertUser({ id, primaryEmail: email, lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
);
router.post(`${registerRoute}/email`, emailRegisterAction(provider));
}

View file

@ -0,0 +1,53 @@
import { PasscodeType } from '@logto/schemas';
import { z } from 'zod';
export const passcodeTypeGuard = z.nativeEnum(PasscodeType);
export const methodGuard = z.enum(['email', 'sms']);
export type Method = z.infer<typeof methodGuard>;
export const operationGuard = z.enum(['send', 'verify']);
export type Operation = z.infer<typeof operationGuard>;
const smsSessionStorageGuard = z.object({
flow: z.literal(PasscodeType.SignIn).or(z.literal(PasscodeType.Register)),
expiresAt: z.string(),
phone: z.string(),
});
export type SmsSessionStorage = z.infer<typeof smsSessionStorageGuard>;
export const smsSessionResultGuard = z.object({ verification: smsSessionStorageGuard });
const emailSessionStorageGuard = z.object({
flow: z.literal(PasscodeType.SignIn).or(z.literal(PasscodeType.Register)),
expiresAt: z.string(),
email: z.string(),
});
export type EmailSessionStorage = z.infer<typeof emailSessionStorageGuard>;
export const emailSessionResultGuard = z.object({
verification: emailSessionStorageGuard,
});
const forgotPasswordSessionStorageGuard = z.object({
flow: z.literal(PasscodeType.ForgotPassword),
expiresAt: z.string(),
userId: z.string(),
});
export type ForgotPasswordSessionStorage = z.infer<typeof forgotPasswordSessionStorageGuard>;
export const forgotPasswordSessionResultGuard = z.object({
verification: forgotPasswordSessionStorageGuard,
});
export type VerificationStorage =
| SmsSessionStorage
| EmailSessionStorage
| ForgotPasswordSessionStorage;
export type VerificationResult<T = VerificationStorage> = { verification: T };

View file

@ -1,4 +1,15 @@
import { logTypeGuard, LogType, PasscodeType } from '@logto/schemas';
import { Truthy } from '@silverhand/essentials';
import dayjs from 'dayjs';
import { Context } from 'koa';
import { Provider } from 'oidc-provider';
import { z, ZodType } from 'zod';
import RequestError from '@/errors/RequestError';
import assertThat from '@/utils/assert-that';
import { verificationTimeout } from './consts';
import { Method, Operation, VerificationResult, VerificationStorage } from './types';
export const getRoutePrefix = (
type: 'sign-in' | 'register' | 'forgot-password',
@ -9,3 +20,75 @@ export const getRoutePrefix = (
.map((value) => '/' + value)
.join('');
};
export const getPasswordlessRelatedLogType = (
flow: PasscodeType,
method: Method,
operation?: Operation
): LogType => {
const body = method === 'email' ? 'Email' : 'Sms';
const suffix = operation === 'send' ? 'SendPasscode' : '';
const result = logTypeGuard.safeParse(flow + body + suffix);
assertThat(result.success, new RequestError('log.invalid_type'));
return result.data;
};
export const getVerificationStorageFromInteraction = async <T = VerificationStorage>(
ctx: Context,
provider: Provider,
resultGuard: ZodType<VerificationResult<T>>
): Promise<T> => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const verificationResult = resultGuard.safeParse(result);
if (!verificationResult.success) {
throw new RequestError(
{
code: 'session.verification_session_not_found',
status: 404,
},
verificationResult.error
);
}
return verificationResult.data.verification;
};
export const checkValidateExpiration = (expiresAt: string) => {
assertThat(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
new RequestError({ code: 'session.verification_expired', status: 401 })
);
};
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
export const assignVerificationResult = async (
ctx: Context,
provider: Provider,
verificationData: DistributiveOmit<VerificationStorage, 'expiresAt'>
) => {
const verification: VerificationStorage = {
...verificationData,
expiresAt: dayjs().add(verificationTimeout, 'second').toISOString(),
};
await provider.interactionResult(ctx.req, ctx.res, {
verification,
});
};
export const clearVerificationResult = async (ctx: Context, provider: Provider) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const verificationGuard = z.object({ verification: z.unknown() });
const verificationGuardResult = verificationGuard.safeParse(result);
if (result && verificationGuardResult.success) {
const { verification, ...rest } = result;
await provider.interactionResult(ctx.req, ctx.res, rest);
}
};

View file

@ -1,3 +1,5 @@
import { PasscodeType } from '@logto/schemas';
import api from './api';
type RedirectResponse = {
@ -51,12 +53,13 @@ export const consent = async (interactionCookie: string) =>
.json<RedirectResponse>();
export const sendRegisterUserWithEmailPasscode = (email: string, interactionCookie: string) =>
api.post('session/register/passwordless/email/send-passcode', {
api.post('session/passwordless/email/send', {
headers: {
cookie: interactionCookie,
},
json: {
email,
flow: PasscodeType.Register,
},
});
@ -66,24 +69,35 @@ export const verifyRegisterUserWithEmailPasscode = (
interactionCookie: string
) =>
api
.post('session/register/passwordless/email/verify-passcode', {
.post('session/passwordless/email/verify', {
headers: {
cookie: interactionCookie,
},
json: {
email,
code,
flow: PasscodeType.Register,
},
})
.json<RedirectResponse>();
export const checkVerificationSessionAndRegisterWithEmail = (interactionCookie: string) =>
api
.post('session/register/passwordless/email', {
headers: {
cookie: interactionCookie,
},
})
.json<RedirectResponse>();
export const sendSignInUserWithEmailPasscode = (email: string, interactionCookie: string) =>
api.post('session/sign-in/passwordless/email/send-passcode', {
api.post('session/passwordless/email/send', {
headers: {
cookie: interactionCookie,
},
json: {
email,
flow: PasscodeType.SignIn,
},
});
@ -93,24 +107,35 @@ export const verifySignInUserWithEmailPasscode = (
interactionCookie: string
) =>
api
.post('session/sign-in/passwordless/email/verify-passcode', {
.post('session/passwordless/email/verify', {
headers: {
cookie: interactionCookie,
},
json: {
email,
code,
flow: PasscodeType.SignIn,
},
})
.json<RedirectResponse>();
export const checkVerificationSessionAndSignInWithEmail = (interactionCookie: string) =>
api
.post('session/sign-in/passwordless/email', {
headers: {
cookie: interactionCookie,
},
})
.json<RedirectResponse>();
export const sendRegisterUserWithSmsPasscode = (phone: string, interactionCookie: string) =>
api.post('session/register/passwordless/sms/send-passcode', {
api.post('session/passwordless/sms/send', {
headers: {
cookie: interactionCookie,
},
json: {
phone,
flow: PasscodeType.Register,
},
});
@ -120,24 +145,35 @@ export const verifyRegisterUserWithSmsPasscode = (
interactionCookie: string
) =>
api
.post('session/register/passwordless/sms/verify-passcode', {
.post('session/passwordless/sms/verify', {
headers: {
cookie: interactionCookie,
},
json: {
phone,
code,
flow: PasscodeType.Register,
},
})
.json<RedirectResponse>();
export const checkVerificationSessionAndRegisterWithSms = (interactionCookie: string) =>
api
.post('session/register/passwordless/sms', {
headers: {
cookie: interactionCookie,
},
})
.json<RedirectResponse>();
export const sendSignInUserWithSmsPasscode = (phone: string, interactionCookie: string) =>
api.post('session/sign-in/passwordless/sms/send-passcode', {
api.post('session/passwordless/sms/send', {
headers: {
cookie: interactionCookie,
},
json: {
phone,
flow: PasscodeType.SignIn,
},
});
@ -147,13 +183,23 @@ export const verifySignInUserWithSmsPasscode = (
interactionCookie: string
) =>
api
.post('session/sign-in/passwordless/sms/verify-passcode', {
.post('session/passwordless/sms/verify', {
headers: {
cookie: interactionCookie,
},
json: {
phone,
code,
flow: PasscodeType.SignIn,
},
})
.json<RedirectResponse>();
export const checkVerificationSessionAndSignInWithSms = (interactionCookie: string) =>
api
.post('session/sign-in/passwordless/sms', {
headers: {
cookie: interactionCookie,
},
})
.json<RedirectResponse>();

View file

@ -48,6 +48,7 @@ const translation = {
'The account with {{type}} {{value}} already exists, would you like to sign in?',
sign_in_id_does_not_exists:
'The account with {{type}} {{value}} does not exist, would you like to create a new account?',
forgot_password_id_does_not_exits: 'The account with {{type}} {{value}} does not exist.',
bind_account_title: 'Link account',
social_create_account: 'No account? You can create a new account and link.',
social_bind_account: 'Already have an account? Sign in to link it with your social identity.',

View file

@ -50,6 +50,7 @@ const translation = {
'Le compte avec {{type}} {{value}} existe déjà, voulez-vous vous connecter ?',
sign_in_id_does_not_exists:
"Le compte avec {{type}} {{value}} n'existe pas, voulez-vous créer un nouveau compte ?",
forgot_password_id_does_not_exits: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
bind_account_title: 'Lier le compte',
social_create_account: 'Pas de compte ? Vous pouvez créer un nouveau compte et un lien.',
social_bind_account:

View file

@ -48,6 +48,7 @@ const translation = {
continue_with: '계속하기',
create_account_id_exists: '{{type}} {{value}} 계정이 이미 존재해요. 로그인하시겠어요?',
sign_in_id_does_not_exists: '{type}} {{value}} 계정이 존재하지 않아요. 새로 만드시겠어요?',
forgot_password_id_does_not_exits: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
bind_account_title: '계정 연동',
social_create_account: '계정이 없으신가요? 새로운 계정을 만들고 연동해보세요.',
social_bind_account: '계정이 이미 있으신가요? 로그인하여 다른 계정과 연동해보세요.',

View file

@ -48,6 +48,7 @@ const translation = {
continue_with: 'Continuar com',
create_account_id_exists: 'A conta com {{type}} {{value}} já existe, gostaria de fazer login?',
sign_in_id_does_not_exists: 'A conta com {{type}} {{value}} não existe, gostaria de criar uma?',
forgot_password_id_does_not_exits: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
bind_account_title: 'Agregar conta',
social_create_account: 'Sem conta? Pode criar uma nova e agregar.',
social_bind_account: 'Já tem uma conta? Faça login para agregar a sua identidade social.',

View file

@ -49,6 +49,7 @@ const translation = {
create_account_id_exists: '{{type}} {{value}} ile hesap mevcut, giriş yapmak ister misiniz?',
sign_in_id_does_not_exists:
'{{type}} {{value}} ile hesap mevcut değil, yeni bir hesap oluşturmak ister misiniz?',
forgot_password_id_does_not_exits: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
bind_account_title: 'Hesap bağla',
social_create_account: 'Hesabınız yok mu? Yeni bir hesap ve bağlantı oluşturabilirsiniz.',
social_bind_account: 'Hesabınız zaten var mı? Hesabınıza bağlanmak için giriş yapınız.',

View file

@ -48,6 +48,7 @@ const translation = {
continue_with: '通过以下方式继续',
create_account_id_exists: '{{ type }}为 {{ value }} 的帐号已存在,你要登录吗?',
sign_in_id_does_not_exists: '{{ type }}为 {{ value }} 的帐号不存在,你要创建一个新帐号吗?',
forgot_password_id_does_not_exits: '{{ type }}为 {{ value }} 的帐号不存在。',
bind_account_title: '绑定帐号',
social_create_account: '没有帐号?你可以创建一个帐号并绑定。',
social_bind_account: '已有帐号?登录以绑定社交身份。',

View file

@ -42,7 +42,7 @@ const errors = {
identity_exists: 'The social account has been registered.',
invalid_role_names: 'role names ({{roleNames}}) are not valid',
cannot_delete_self: 'You cannot delete yourself.',
same_password: 'Your new password can not be the same as current password.',
same_password: 'Your new password cant be the same as your current password.',
},
password: {
unsupported_encryption_method: 'The encryption method {{name}} is not supported.',
@ -56,10 +56,10 @@ const errors = {
insufficient_info: 'Insufficient sign-in info.',
connector_id_mismatch: 'The connectorId is mismatched with session record.',
connector_session_not_found: 'Connector session not found. Please go back and sign in again.',
forgot_password_session_not_found:
'Forgot password session not found. Please go back and verify.',
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.',
verification_session_not_found:
'The verification was not successful. Restart the verification flow and try again.',
verification_expired:
'The connection has timed out. Verify again to ensure your account safety.',
unauthorized: 'Please sign in first.',
unsupported_prompt_name: 'Unsupported prompt name.',
},
@ -123,6 +123,9 @@ const errors = {
not_exists_with_id: 'The {{name}} with ID `{{id}}` does not exist.',
not_found: 'The resource does not exist.',
},
log: {
invalid_type: 'The log type is invalid.',
},
};
export default errors;

View file

@ -43,7 +43,7 @@ const errors = {
identity_exists: 'Le compte social a été enregistré.',
invalid_role_names: 'les noms de rôles ({{roleNames}}) ne sont pas valides',
cannot_delete_self: 'You cannot delete yourself.', // UNTRANSLATED
same_password: 'Your new password can not be the same as current password.', // UNTRANSLATED
same_password: 'Your new password cant be the same as your current password.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: "La méthode de cryptage {{name}} n'est pas prise en charge.",
@ -61,10 +61,10 @@ const errors = {
connector_id_mismatch: "Le connectorId ne correspond pas à l'enregistrement de la session.",
connector_session_not_found:
"La session du connecteur n'a pas été trouvée. Veuillez revenir en arrière et vous connecter à nouveau.",
forgot_password_session_not_found:
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
verification_session_not_found:
'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED
verification_expired:
'The connection has timed out. Verify again to ensure your account safety.', // UNTRANSLATED
unauthorized: "Veuillez vous enregistrer d'abord.",
unsupported_prompt_name: "Nom d'invite non supporté.",
},
@ -131,6 +131,9 @@ const errors = {
not_exists_with_id: "Le {{name}} avec l'ID `{{id}}` n'existe pas.",
not_found: "La ressource n'existe pas.",
},
log: {
invalid_type: 'The log type is invalid.', // UNTRANSLATED
},
};
export default errors;

View file

@ -41,7 +41,7 @@ const errors = {
identity_exists: '소셜 계정이 이미 등록되있어요.',
invalid_role_names: '직책 명({{roleNames}})이 유효하지 않아요.',
cannot_delete_self: 'You cannot delete yourself.', // UNTRANSLATED
same_password: 'Your new password can not be the same as current password.', // UNTRANSLATED
same_password: 'Your new password cant be the same as your current password.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: '{{name}} 암호화 방법을 지원하지 않아요.',
@ -55,10 +55,10 @@ const errors = {
insufficient_info: '로그인 정보가 충분하지 않아요.',
connector_id_mismatch: '연동 ID가 세션 정보와 일치하지 않아요.',
connector_session_not_found: '연동 세션을 찾을 수 없어요. 다시 로그인해주세요.',
forgot_password_session_not_found:
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
verification_session_not_found:
'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED
verification_expired:
'The connection has timed out. Verify again to ensure your account safety.', // UNTRANSLATED
unauthorized: '로그인을 먼저 해주세요.',
unsupported_prompt_name: '지원하지 않는 Prompt 이름이예요.',
},
@ -120,6 +120,9 @@ const errors = {
not_exists_with_id: '{{id}} ID를 가진 {{name}}는 존재하지 않아요.',
not_found: '리소스가 존재하지 않아요.',
},
log: {
invalid_type: 'The log type is invalid.', // UNTRANSLATED
},
};
export default errors;

View file

@ -41,7 +41,7 @@ const errors = {
identity_exists: 'A conta social foi registada.',
invalid_role_names: '({{roleNames}}) não são válidos',
cannot_delete_self: 'Não se pode remover a si mesmo.',
same_password: 'Your new password can not be the same as current password.', // UNTRANSLATED
same_password: 'Your new password cant be the same as your current password.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: 'O método de enncriptação {{name}} não é suportado.',
@ -57,10 +57,10 @@ const errors = {
connector_id_mismatch: 'O connectorId não corresponde ao registado na sessão.',
connector_session_not_found:
'Sessão do conector não encontrada. Por favor, volte e faça login novamente.',
forgot_password_session_not_found:
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
verification_session_not_found:
'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED
verification_expired:
'The connection has timed out. Verify again to ensure your account safety.', // UNTRANSLATED
unauthorized: 'Faça login primeiro.',
unsupported_prompt_name: 'Nome de prompt não suportado.',
},
@ -126,6 +126,9 @@ const errors = {
not_exists_with_id: '{{name}} com o ID `{{id}}` não existe.',
not_found: 'O recurso não existe.',
},
log: {
invalid_type: 'The log type is invalid.', // UNTRANSLATED
},
};
export default errors;

View file

@ -42,7 +42,7 @@ const errors = {
identity_exists: 'Sosyal platform hesabı kaydedildi.',
invalid_role_names: '({{roleNames}}) rol adları geçerli değil.',
cannot_delete_self: 'You cannot delete yourself.', // UNTRANSLATED
same_password: 'Your new password can not be the same as current password.', // UNTRANSLATED
same_password: 'Your new password cant be the same as your current password.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: '{{name}} şifreleme metodu desteklenmiyor.',
@ -57,10 +57,10 @@ const errors = {
connector_id_mismatch: 'connectorId, oturum kaydı ile eşleşmiyor.',
connector_session_not_found:
'Bağlayıcı oturum bulunamadı. Lütfen geri dönüp tekrardan giriş yapınız.',
forgot_password_session_not_found:
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
verification_session_not_found:
'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED
verification_expired:
'The connection has timed out. Verify again to ensure your account safety.', // UNTRANSLATED
unauthorized: 'Lütfen önce oturum açın.',
unsupported_prompt_name: 'Desteklenmeyen prompt adı.',
},
@ -125,6 +125,9 @@ const errors = {
not_exists_with_id: ' `{{id}}` id kimliğine sahip {{name}} mevcut değil.',
not_found: 'Kaynak mevcut değil.',
},
log: {
invalid_type: 'The log type is invalid.', // UNTRANSLATED
},
};
export default errors;

View file

@ -41,7 +41,7 @@ const errors = {
identity_exists: '该社交帐号已被注册',
invalid_role_names: '角色名称({{roleNames}})无效',
cannot_delete_self: '你无法删除自己',
same_password: '新设置的密码不可与当前密码相同',
same_password: '为确保你的账户安全,新密码不能与旧密码一致',
},
password: {
unsupported_encryption_method: '不支持的加密方法 {{name}}',
@ -55,8 +55,8 @@ const errors = {
insufficient_info: '登录信息缺失,请检查你的输入。',
connector_id_mismatch: '传入的连接器 ID 与 session 中保存的记录不一致',
connector_session_not_found: '无法找到连接器登录信息,请尝试重新登录。',
forgot_password_session_not_found: '无法找到忘记密码验证信息,请尝试重新验证。',
forgot_password_verification_expired: '忘记密码验证已过期,请尝试重新验证。',
verification_session_not_found: '验证失败,请重新验证。',
verification_expired: '无密码验证已过期。请返回重新验证。',
unauthorized: '请先登录',
unsupported_prompt_name: '不支持的 prompt name',
},
@ -114,6 +114,9 @@ const errors = {
not_exists_with_id: 'ID 为 `{{id}}` 的 {{name}} 不存在',
not_found: '该资源不存在',
},
log: {
invalid_type: 'The log type is invalid.', // UNTRANSLATED
},
};
export default errors;

View file

@ -1,3 +1,5 @@
import { PasscodeType } from '@logto/schemas';
import api from './api';
type Response = {
@ -8,9 +10,10 @@ const forgotPasswordApiPrefix = '/api/session/forgot-password';
export const sendForgotPasswordSmsPasscode = async (phone: string) => {
await api
.post(`${forgotPasswordApiPrefix}/sms/send-passcode`, {
.post('/api/session/passwordless/sms/send', {
json: {
phone,
flow: PasscodeType.ForgotPassword,
},
})
.json();
@ -20,22 +23,24 @@ export const sendForgotPasswordSmsPasscode = async (phone: string) => {
export const verifyForgotPasswordSmsPasscode = async (phone: string, code: string) => {
await api
.post(`${forgotPasswordApiPrefix}/sms/verify-passcode`, {
.post('/api/session/passwordless/sms/verify', {
json: {
phone,
code,
flow: PasscodeType.ForgotPassword,
},
})
.json<Response>();
.json();
return { success: true };
};
export const sendForgotPasswordEmailPasscode = async (email: string) => {
await api
.post(`${forgotPasswordApiPrefix}/email/send-passcode`, {
.post('/api/session/passwordless/email/send', {
json: {
email,
flow: PasscodeType.ForgotPassword,
},
})
.json();
@ -45,13 +50,14 @@ export const sendForgotPasswordEmailPasscode = async (email: string) => {
export const verifyForgotPasswordEmailPasscode = async (email: string, code: string) => {
await api
.post(`${forgotPasswordApiPrefix}/email/verify-passcode`, {
.post('/api/session/passwordless/email/verify', {
json: {
email,
code,
flow: PasscodeType.ForgotPassword,
},
})
.json<Response>();
.json();
return { success: true };
};

View file

@ -1,3 +1,4 @@
import { PasscodeType } from '@logto/schemas';
import ky from 'ky';
import { consent } from './consent';
@ -10,6 +11,8 @@ import {
} from './forgot-password';
import {
register,
registerWithSms,
registerWithEmail,
sendRegisterEmailPasscode,
sendRegisterSmsPasscode,
verifyRegisterEmailPasscode,
@ -17,6 +20,8 @@ import {
} from './register';
import {
signInBasic,
signInWithSms,
signInWithEmail,
sendSignInSmsPasscode,
sendSignInEmailPasscode,
verifySignInEmailPasscode,
@ -65,6 +70,26 @@ describe('api', () => {
});
});
it('signInWithSms', async () => {
mockKyPost.mockReturnValueOnce({
json: () => ({
redirectTo: '/',
}),
});
await signInWithSms();
expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/sms');
});
it('signInWithEmail', async () => {
mockKyPost.mockReturnValueOnce({
json: () => ({
redirectTo: '/',
}),
});
await signInWithEmail();
expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/email');
});
it('signInBasic with bind social account', async () => {
mockKyPost.mockReturnValueOnce({
json: () => ({
@ -87,9 +112,10 @@ describe('api', () => {
it('sendSignInSmsPasscode', async () => {
await sendSignInSmsPasscode(phone);
expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/sms/send-passcode', {
expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/send', {
json: {
phone,
flow: PasscodeType.SignIn,
},
});
});
@ -100,20 +126,24 @@ describe('api', () => {
redirectTo: '/',
}),
});
await verifySignInSmsPasscode(phone, code);
expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/sms/verify-passcode', {
expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/verify', {
json: {
phone,
code,
flow: PasscodeType.SignIn,
},
});
});
it('sendSignInEmailPasscode', async () => {
await sendSignInEmailPasscode(email);
expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/email/send-passcode', {
expect(ky.post).toBeCalledWith('/api/session/passwordless/email/send', {
json: {
email,
flow: PasscodeType.SignIn,
},
});
});
@ -124,11 +154,14 @@ describe('api', () => {
redirectTo: '/',
}),
});
await verifySignInEmailPasscode(email, code);
expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/email/verify-passcode', {
expect(ky.post).toBeCalledWith('/api/session/passwordless/email/verify', {
json: {
email,
code,
flow: PasscodeType.SignIn,
},
});
});
@ -148,78 +181,96 @@ describe('api', () => {
});
});
it('registerWithSms', async () => {
await registerWithSms();
expect(ky.post).toBeCalledWith('/api/session/register/passwordless/sms');
});
it('registerWithEmail', async () => {
await registerWithEmail();
expect(ky.post).toBeCalledWith('/api/session/register/passwordless/email');
});
it('sendRegisterSmsPasscode', async () => {
await sendRegisterSmsPasscode(phone);
expect(ky.post).toBeCalledWith('/api/session/register/passwordless/sms/send-passcode', {
expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/send', {
json: {
phone,
flow: PasscodeType.Register,
},
});
});
it('verifyRegisterSmsPasscode', async () => {
await verifyRegisterSmsPasscode(phone, code);
expect(ky.post).toBeCalledWith('/api/session/register/passwordless/sms/verify-passcode', {
expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/verify', {
json: {
phone,
code,
flow: PasscodeType.Register,
},
});
});
it('sendRegisterEmailPasscode', async () => {
await sendRegisterEmailPasscode(email);
expect(ky.post).toBeCalledWith('/api/session/register/passwordless/email/send-passcode', {
expect(ky.post).toBeCalledWith('/api/session/passwordless/email/send', {
json: {
email,
flow: PasscodeType.Register,
},
});
});
it('verifyRegisterEmailPasscode', async () => {
await verifyRegisterEmailPasscode(email, code);
expect(ky.post).toBeCalledWith('/api/session/register/passwordless/email/verify-passcode', {
expect(ky.post).toBeCalledWith('/api/session/passwordless/email/verify', {
json: {
email,
code,
flow: PasscodeType.Register,
},
});
});
it('sendForgotPasswordSmsPasscode', async () => {
await sendForgotPasswordSmsPasscode(phone);
expect(ky.post).toBeCalledWith('/api/session/forgot-password/sms/send-passcode', {
expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/send', {
json: {
phone,
flow: PasscodeType.ForgotPassword,
},
});
});
it('verifyForgotPasswordSmsPasscode', async () => {
await verifyForgotPasswordSmsPasscode(phone, code);
expect(ky.post).toBeCalledWith('/api/session/forgot-password/sms/verify-passcode', {
expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/verify', {
json: {
phone,
code,
flow: PasscodeType.ForgotPassword,
},
});
});
it('sendForgotPasswordEmailPasscode', async () => {
await sendForgotPasswordEmailPasscode(email);
expect(ky.post).toBeCalledWith('/api/session/forgot-password/email/send-passcode', {
expect(ky.post).toBeCalledWith('/api/session/passwordless/email/send', {
json: {
email,
flow: PasscodeType.ForgotPassword,
},
});
});
it('verifyForgotPasswordEmailPasscode', async () => {
await verifyForgotPasswordEmailPasscode(email, code);
expect(ky.post).toBeCalledWith('/api/session/forgot-password/email/verify-passcode', {
expect(ky.post).toBeCalledWith('/api/session/passwordless/email/verify', {
json: {
email,
code,
flow: PasscodeType.ForgotPassword,
},
});
});

View file

@ -1,14 +1,16 @@
import { PasscodeType } from '@logto/schemas';
import api from './api';
const registerApiPrefix = '/api/session/register';
const apiPrefix = '/api/session';
type Response = {
redirectTo: string;
};
export const register = async (username: string, password: string) => {
type Response = {
redirectTo: string;
};
return api
.post(`${registerApiPrefix}/username-password`, {
.post(`${apiPrefix}/register/username-password`, {
json: {
username,
password,
@ -17,11 +19,18 @@ export const register = async (username: string, password: string) => {
.json<Response>();
};
export const registerWithSms = async () =>
api.post(`${apiPrefix}/register/passwordless/sms`).json<Response>();
export const registerWithEmail = async () =>
api.post(`${apiPrefix}/register/passwordless/email`).json<Response>();
export const sendRegisterSmsPasscode = async (phone: string) => {
await api
.post(`${registerApiPrefix}/passwordless/sms/send-passcode`, {
.post(`${apiPrefix}/passwordless/sms/send`, {
json: {
phone,
flow: PasscodeType.Register,
},
})
.json();
@ -29,26 +38,23 @@ export const sendRegisterSmsPasscode = async (phone: string) => {
return { success: true };
};
export const verifyRegisterSmsPasscode = async (phone: string, code: string) => {
type Response = {
redirectTo: string;
};
return api
.post(`${registerApiPrefix}/passwordless/sms/verify-passcode`, {
export const verifyRegisterSmsPasscode = async (phone: string, code: string) =>
api
.post(`${apiPrefix}/passwordless/sms/verify`, {
json: {
phone,
code,
flow: PasscodeType.Register,
},
})
.json<Response>();
};
export const sendRegisterEmailPasscode = async (email: string) => {
await api
.post(`${registerApiPrefix}/passwordless/email/send-passcode`, {
.post(`${apiPrefix}/passwordless/email/send`, {
json: {
email,
flow: PasscodeType.Register,
},
})
.json();
@ -56,17 +62,13 @@ export const sendRegisterEmailPasscode = async (email: string) => {
return { success: true };
};
export const verifyRegisterEmailPasscode = async (email: string, code: string) => {
type Response = {
redirectTo: string;
};
return api
.post(`${registerApiPrefix}/passwordless/email/verify-passcode`, {
export const verifyRegisterEmailPasscode = async (email: string, code: string) =>
api
.post(`${apiPrefix}/passwordless/email/verify`, {
json: {
email,
code,
flow: PasscodeType.Register,
},
})
.json<Response>();
};

View file

@ -1,13 +1,17 @@
import { PasscodeType } from '@logto/schemas';
import api from './api';
import { bindSocialAccount } from './social';
export const signInBasic = async (username: string, password: string, socialToBind?: string) => {
type Response = {
redirectTo: string;
};
const apiPrefix = '/api/session';
type Response = {
redirectTo: string;
};
export const signInBasic = async (username: string, password: string, socialToBind?: string) => {
const result = await api
.post('/api/session/sign-in/username-password', {
.post(`${apiPrefix}/sign-in/username-password`, {
json: {
username,
password,
@ -22,11 +26,32 @@ export const signInBasic = async (username: string, password: string, socialToBi
return result;
};
export const signInWithSms = async (socialToBind?: string) => {
const result = await api.post(`${apiPrefix}/sign-in/passwordless/sms`).json<Response>();
if (result.redirectTo && socialToBind) {
await bindSocialAccount(socialToBind);
}
return result;
};
export const signInWithEmail = async (socialToBind?: string) => {
const result = await api.post(`${apiPrefix}/sign-in/passwordless/email`).json<Response>();
if (result.redirectTo && socialToBind) {
await bindSocialAccount(socialToBind);
}
return result;
};
export const sendSignInSmsPasscode = async (phone: string) => {
await api
.post('/api/session/sign-in/passwordless/sms/send-passcode', {
.post(`${apiPrefix}/passwordless/sms/send`, {
json: {
phone,
flow: PasscodeType.SignIn,
},
})
.json();
@ -39,15 +64,12 @@ export const verifySignInSmsPasscode = async (
code: string,
socialToBind?: string
) => {
type Response = {
redirectTo: string;
};
const result = await api
.post('/api/session/sign-in/passwordless/sms/verify-passcode', {
.post(`${apiPrefix}/passwordless/sms/verify`, {
json: {
phone,
code,
flow: PasscodeType.SignIn,
},
})
.json<Response>();
@ -61,9 +83,10 @@ export const verifySignInSmsPasscode = async (
export const sendSignInEmailPasscode = async (email: string) => {
await api
.post('/api/session/sign-in/passwordless/email/send-passcode', {
.post(`${apiPrefix}/passwordless/email/send`, {
json: {
email,
flow: PasscodeType.SignIn,
},
})
.json();
@ -76,15 +99,12 @@ export const verifySignInEmailPasscode = async (
code: string,
socialToBind?: string
) => {
type Response = {
redirectTo: string;
};
const result = await api
.post('/api/session/sign-in/passwordless/email/verify-passcode', {
.post(`${apiPrefix}/passwordless/email/verify`, {
json: {
email,
code,
flow: PasscodeType.SignIn,
},
})
.json<Response>();

View file

@ -8,12 +8,12 @@ import { getSendPasscodeApi, getVerifyPasscodeApi } from '@/apis/utils';
import Passcode, { defaultLength } from '@/components/Passcode';
import TextLink from '@/components/TextLink';
import useApi, { ErrorHandlers } from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { PageContext } from '@/hooks/use-page-context';
import { UserFlow, SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import * as styles from './index.module.scss';
import usePasscodeValidationErrorHandler from './use-passcode-validation-error-handler';
type Props = {
type: UserFlow;
@ -36,7 +36,6 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => {
const [error, setError] = useState<string>();
const { setToast } = useContext(PageContext);
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const { seconds, isRunning, restart } = useTimer({
@ -44,19 +43,15 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => {
expiryTimestamp: getTimeout(),
});
// Get the flow specific error handler hook
const { errorHandler } = usePasscodeValidationErrorHandler(type, method, target);
const verifyPasscodeErrorHandlers: ErrorHandlers = useMemo(
() => ({
...errorHandler,
'passcode.expired': (error) => {
setError(error.message);
},
'user.phone_not_exists': async (error) => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);
},
'user.email_not_exists': async (error) => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);
},
'passcode.code_mismatch': (error) => {
setError(error.message);
},
@ -64,7 +59,7 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => {
setCode([]);
},
}),
[navigate, show]
[errorHandler]
);
const { result: verifyPasscodeResult, run: verifyPassCode } = useApi(

View file

@ -0,0 +1,37 @@
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { ErrorHandlers } from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
const useForgotPasswordWithEmailErrorHandler = (email: string) => {
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const emailNotExistForgotPasswordHandler = useCallback(async () => {
await show({
type: 'alert',
ModalContent: t('description.forgot_password_id_does_not_exits', {
type: t(`description.email`),
value: email,
}),
cancelText: 'action.got_it',
});
navigate(-1);
}, [navigate, show, t, email]);
const errorHandler = useMemo<ErrorHandlers>(
() => ({
'user.email_not_exists': emailNotExistForgotPasswordHandler,
}),
[emailNotExistForgotPasswordHandler]
);
return {
errorHandler,
};
};
export default useForgotPasswordWithEmailErrorHandler;

View file

@ -0,0 +1,38 @@
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { ErrorHandlers } from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
const useForgotPasswordWithSmsErrorHandler = (phone: string) => {
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const phoneNotExistForgotPasswordHandler = useCallback(async () => {
await show({
type: 'alert',
ModalContent: t('description.forgot_password_id_does_not_exits', {
type: t(`description.phone_number`),
value: formatPhoneNumberWithCountryCallingCode(phone),
}),
cancelText: 'action.got_it',
});
navigate(-1);
}, [navigate, show, t, phone]);
const errorHandler = useMemo<ErrorHandlers>(
() => ({
'user.phone_not_exists': phoneNotExistForgotPasswordHandler,
}),
[phoneNotExistForgotPasswordHandler]
);
return {
errorHandler,
};
};
export default useForgotPasswordWithSmsErrorHandler;

View file

@ -0,0 +1,43 @@
import { UserFlow } from '@/types';
import useForgotPasswordWithEmailErrorHandler from './use-forgot-password-with-email-error-handler';
import useForgotPasswordWithSmsErrorHandler from './use-forgot-password-with-sms-error-handler';
import useRegisterWithSmsErrorHandler from './use-register-with-sms-error-handler';
import useSignInWithEmailErrorHandler from './use-sign-in-with-email-error-handler';
import useSignInWithSmsErrorHandler from './use-sign-in-with-sms-error-handler';
import useRegisterWithEmailErrorHandler from './user-register-with-email-error-handler';
type Method = 'email' | 'sms';
const getPasscodeValidationErrorHandlersByFlowAndMethod = (flow: UserFlow, method: Method) => {
if (flow === 'sign-in' && method === 'email') {
return useSignInWithEmailErrorHandler;
}
if (flow === 'sign-in' && method === 'sms') {
return useSignInWithSmsErrorHandler;
}
if (flow === 'register' && method === 'email') {
return useRegisterWithEmailErrorHandler;
}
if (flow === 'register' && method === 'sms') {
return useRegisterWithSmsErrorHandler;
}
if (flow === 'forgot-password' && method === 'email') {
return useForgotPasswordWithEmailErrorHandler;
}
return useForgotPasswordWithSmsErrorHandler;
};
const usePasscodeValidationErrorHandler = (type: UserFlow, method: Method, target: string) => {
const useFlowErrorHandler = getPasscodeValidationErrorHandlersByFlowAndMethod(type, method);
const { errorHandler } = useFlowErrorHandler(target);
return { errorHandler };
};
export default usePasscodeValidationErrorHandler;

View file

@ -0,0 +1,53 @@
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { signInWithSms } from '@/apis/sign-in';
import useApi, { ErrorHandlers } from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
const useRegisterWithSmsErrorHandler = (phone: string) => {
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const { run: signInWithSmsAsync } = useApi(signInWithSms);
const phoneExistRegisterHandler = useCallback(async () => {
const [confirm] = await show({
confirmText: 'action.sign_in',
ModalContent: t('description.create_account_id_exists', {
type: t(`description.phone_number`),
value: formatPhoneNumberWithCountryCallingCode(phone),
}),
});
if (!confirm) {
navigate(-1);
return;
}
const result = await signInWithSmsAsync();
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [navigate, phone, show, signInWithSmsAsync, t]);
const errorHandler = useMemo<ErrorHandlers>(
() => ({
'user.phone_exists_register': async () => {
await phoneExistRegisterHandler();
},
}),
[phoneExistRegisterHandler]
);
return {
errorHandler,
};
};
export default useRegisterWithSmsErrorHandler;

View file

@ -0,0 +1,63 @@
import { useCallback, useMemo, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { registerWithEmail } from '@/apis/register';
import useApi, { ErrorHandlers } from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { PageContext } from '@/hooks/use-page-context';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
const useSignInWithEmailErrorHandler = (email: string) => {
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const { setToast } = useContext(PageContext);
const { run: registerWithEmailAsync } = useApi(registerWithEmail);
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
const emailNotExistSignInHandler = useCallback(async () => {
const [confirm] = await show({
confirmText: 'action.create',
ModalContent: t('description.sign_in_id_does_not_exists', {
type: t(`description.email`),
value: email,
}),
});
if (!confirm) {
navigate(-1);
return;
}
const result = await registerWithEmailAsync();
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [navigate, registerWithEmailAsync, show, t, email]);
const errorHandler = useMemo<ErrorHandlers>(
() => ({
'user.email_not_exists': async (error) => {
// Directly display the error if user is trying to bind with social
if (socialToBind) {
setToast(error.message);
}
await emailNotExistSignInHandler();
},
}),
[emailNotExistSignInHandler, setToast, socialToBind]
);
return {
errorHandler,
};
};
export default useSignInWithEmailErrorHandler;

View file

@ -0,0 +1,64 @@
import { useCallback, useMemo, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { registerWithSms } from '@/apis/register';
import useApi, { ErrorHandlers } from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { PageContext } from '@/hooks/use-page-context';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
const useSignInWithSmsErrorHandler = (phone: string) => {
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const { setToast } = useContext(PageContext);
const { run: registerWithSmsAsync } = useApi(registerWithSms);
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
const phoneNotExistSignInHandler = useCallback(async () => {
const [confirm] = await show({
ModalContent: t('description.sign_in_id_does_not_exists', {
confirmText: 'action.create',
type: t(`description.phone_number`),
value: formatPhoneNumberWithCountryCallingCode(phone),
}),
});
if (!confirm) {
navigate(-1);
return;
}
const result = await registerWithSmsAsync();
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [navigate, registerWithSmsAsync, show, t, phone]);
const errorHandler = useMemo<ErrorHandlers>(
() => ({
'user.phone_not_exists': async (error) => {
// Directly display the error if user is trying to bind with social
if (socialToBind) {
setToast(error.message);
}
await phoneNotExistSignInHandler();
},
}),
[phoneNotExistSignInHandler, setToast, socialToBind]
);
return {
errorHandler,
};
};
export default useSignInWithSmsErrorHandler;

View file

@ -0,0 +1,52 @@
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { signInWithEmail } from '@/apis/sign-in';
import useApi, { ErrorHandlers } from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
const useRegisterWithEmailErrorHandler = (email: string) => {
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const { run: signInWithEmailAsync } = useApi(signInWithEmail);
const emailExistRegisterHandler = useCallback(async () => {
const [confirm] = await show({
ModalContent: t('description.create_account_id_exists', {
confirmText: 'action.sign_in',
type: t(`description.email`),
value: email,
}),
});
if (!confirm) {
navigate(-1);
return;
}
const result = await signInWithEmailAsync();
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [navigate, show, signInWithEmailAsync, t, email]);
const errorHandler = useMemo<ErrorHandlers>(
() => ({
'user.email_exists_register': async () => {
await emailExistRegisterHandler();
},
}),
[emailExistRegisterHandler]
);
return {
errorHandler,
};
};
export default useRegisterWithEmailErrorHandler;

View file

@ -1,5 +1,5 @@
import classNames from 'classnames';
import { useCallback, useEffect, useState, useMemo, useContext } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@ -9,13 +9,10 @@ import Input from '@/components/Input';
import TermsOfUse from '@/containers/TermsOfUse';
import useApi, { ErrorHandlers } from '@/hooks/use-api';
import useForm from '@/hooks/use-form';
import { PageContext } from '@/hooks/use-page-context';
import useTerms from '@/hooks/use-terms';
import { UserFlow, SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import { UserFlow } from '@/types';
import { emailValidation } from '@/utils/field-validations';
import PasswordlessConfirmModal from './PasswordlessConfirmModal';
import PasswordlessSwitch from './PasswordlessSwitch';
import * as styles from './index.module.scss';
@ -41,7 +38,6 @@ const EmailPasswordless = ({
hasSwitch = false,
className,
}: Props) => {
const { setToast } = useContext(PageContext);
const { t } = useTranslation();
const navigate = useNavigate();
@ -49,30 +45,13 @@ const EmailPasswordless = ({
const { fieldValue, setFieldValue, setFieldErrors, register, validateForm } =
useForm(defaultState);
const [showPasswordlessConfirmModal, setShowPasswordlessConfirmModal] = useState(false);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'user.email_not_exists': (error) => {
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
// Directly display the error if user is trying to bind with social
if (socialToBind) {
setToast(error.message);
return;
}
setShowPasswordlessConfirmModal(true);
},
'user.email_exists_register': () => {
setShowPasswordlessConfirmModal(true);
},
'guard.invalid_input': () => {
setFieldErrors({ email: 'invalid_email' });
},
}),
[setFieldErrors, setToast]
[setFieldErrors]
);
const sendPasscode = getSendPasscodeApi(type, 'email');
@ -95,10 +74,6 @@ const EmailPasswordless = ({
[validateForm, hasTerms, termsValidation, asyncSendPasscode, fieldValue.email]
);
const onModalCloseHandler = useCallback(() => {
setShowPasswordlessConfirmModal(false);
}, []);
useEffect(() => {
if (result) {
navigate(
@ -112,39 +87,30 @@ const EmailPasswordless = ({
}, [fieldValue.email, navigate, result, type]);
return (
<>
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<div className={styles.formFields}>
<Input
type="email"
name="email"
autoComplete="email"
inputMode="email"
placeholder={t('input.email')}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={styles.inputField}
{...register('email', emailValidation)}
onClear={() => {
setFieldValue((state) => ({ ...state, email: '' }));
}}
/>
{hasSwitch && <PasswordlessSwitch target="sms" className={styles.switch} />}
</div>
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<div className={styles.formFields}>
<Input
type="email"
name="email"
autoComplete="email"
inputMode="email"
placeholder={t('input.email')}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={styles.inputField}
{...register('email', emailValidation)}
onClear={() => {
setFieldValue((state) => ({ ...state, email: '' }));
}}
/>
{hasSwitch && <PasswordlessSwitch target="sms" className={styles.switch} />}
</div>
{hasTerms && <TermsOfUse className={styles.terms} />}
<Button title="action.continue" onClick={async () => onSubmitHandler()} />
{hasTerms && <TermsOfUse className={styles.terms} />}
<Button title="action.continue" onClick={async () => onSubmitHandler()} />
<input hidden type="submit" />
</form>
<PasswordlessConfirmModal
isOpen={showPasswordlessConfirmModal}
type={type === 'sign-in' ? 'register' : 'sign-in'}
method="email"
value={fieldValue.email}
onClose={onModalCloseHandler}
/>
</>
<input hidden type="submit" />
</form>
);
};

View file

@ -1,67 +0,0 @@
import { useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { getSendPasscodeApi, PasscodeChannel } from '@/apis/utils';
import { WebModal, MobileModal } from '@/components/ConfirmModal';
import useApi from '@/hooks/use-api';
import usePlatform from '@/hooks/use-platform';
import { UserFlow } from '@/types';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
type Props = {
className?: string;
isOpen?: boolean;
type: UserFlow;
method: PasscodeChannel;
value: string;
onClose: () => void;
};
const PasswordlessConfirmModal = ({ className, isOpen, type, method, value, onClose }: Props) => {
const { t } = useTranslation();
const sendPasscode = getSendPasscodeApi(type, method);
const navigate = useNavigate();
const { isMobile } = usePlatform();
const { result, run: asyncSendPasscode } = useApi(sendPasscode);
const ConfirmModal = isMobile ? MobileModal : WebModal;
const onConfirmHandler = useCallback(() => {
onClose();
void asyncSendPasscode(value);
}, [asyncSendPasscode, onClose, value]);
useEffect(() => {
if (result) {
navigate(
{
pathname: `/${type}/${method}/passcode-validation`,
},
{ state: { [method]: value } }
);
}
}, [method, result, type, value, navigate, onClose]);
return (
<ConfirmModal
className={className}
isOpen={isOpen}
confirmText={type === 'sign-in' ? 'action.sign_in' : 'action.create'}
onClose={onClose}
onConfirm={onConfirmHandler}
>
{t(
type === 'sign-in'
? 'description.create_account_id_exists'
: 'description.sign_in_id_does_not_exists',
{
type: t(`description.${method === 'email' ? 'email' : 'phone_number'}`),
value: method === 'sms' ? formatPhoneNumberWithCountryCallingCode(value) : value,
}
)}
</ConfirmModal>
);
};
export default PasswordlessConfirmModal;

View file

@ -1,5 +1,5 @@
import classNames from 'classnames';
import { useCallback, useEffect, useState, useMemo, useContext } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@ -9,13 +9,10 @@ import { PhoneInput } from '@/components/Input';
import TermsOfUse from '@/containers/TermsOfUse';
import useApi, { ErrorHandlers } from '@/hooks/use-api';
import useForm from '@/hooks/use-form';
import { PageContext } from '@/hooks/use-page-context';
import usePhoneNumber from '@/hooks/use-phone-number';
import useTerms from '@/hooks/use-terms';
import { UserFlow, SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import { UserFlow } from '@/types';
import PasswordlessConfirmModal from './PasswordlessConfirmModal';
import PasswordlessSwitch from './PasswordlessSwitch';
import * as styles from './index.module.scss';
@ -41,7 +38,6 @@ const PhonePasswordless = ({
hasSwitch = false,
className,
}: Props) => {
const { setToast } = useContext(PageContext);
const { t } = useTranslation();
const { termsValidation } = useTerms();
@ -50,30 +46,13 @@ const PhonePasswordless = ({
const { fieldValue, setFieldValue, setFieldErrors, validateForm, register } =
useForm(defaultState);
const [showPasswordlessConfirmModal, setShowPasswordlessConfirmModal] = useState(false);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'user.phone_not_exists': (error) => {
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
// Directly display the error if user is trying to bind with social
if (socialToBind) {
setToast(error.message);
return;
}
setShowPasswordlessConfirmModal(true);
},
'user.phone_exists_register': () => {
setShowPasswordlessConfirmModal(true);
},
'guard.invalid_input': () => {
setFieldErrors({ phone: 'invalid_phone' });
},
}),
[setFieldErrors, setToast]
[setFieldErrors]
);
const sendPasscode = getSendPasscodeApi(type, 'sms');
@ -105,10 +84,6 @@ const PhonePasswordless = ({
[validateForm, hasTerms, termsValidation, asyncSendPasscode, fieldValue.phone]
);
const onModalCloseHandler = useCallback(() => {
setShowPasswordlessConfirmModal(false);
}, []);
useEffect(() => {
// Sync phoneNumber
setFieldValue((previous) => ({
@ -127,40 +102,31 @@ const PhonePasswordless = ({
}, [fieldValue.phone, navigate, result, type]);
return (
<>
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<div className={styles.formFields}>
<PhoneInput
name="phone"
placeholder={t('input.phone_number')}
className={styles.inputField}
countryCallingCode={phoneNumber.countryCallingCode}
nationalNumber={phoneNumber.nationalNumber}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
countryList={countryList}
{...register('phone', phoneNumberValidation)}
onChange={(data) => {
setPhoneNumber((previous) => ({ ...previous, ...data }));
}}
/>
{hasSwitch && <PasswordlessSwitch target="email" className={styles.switch} />}
</div>
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<div className={styles.formFields}>
<PhoneInput
name="phone"
placeholder={t('input.phone_number')}
className={styles.inputField}
countryCallingCode={phoneNumber.countryCallingCode}
nationalNumber={phoneNumber.nationalNumber}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
countryList={countryList}
{...register('phone', phoneNumberValidation)}
onChange={(data) => {
setPhoneNumber((previous) => ({ ...previous, ...data }));
}}
/>
{hasSwitch && <PasswordlessSwitch target="email" className={styles.switch} />}
</div>
{hasTerms && <TermsOfUse className={styles.terms} />}
{hasTerms && <TermsOfUse className={styles.terms} />}
<Button title="action.continue" onClick={async () => onSubmitHandler()} />
<Button title="action.continue" onClick={async () => onSubmitHandler()} />
<input hidden type="submit" />
</form>
<PasswordlessConfirmModal
isOpen={showPasswordlessConfirmModal}
type={type === 'sign-in' ? 'register' : 'sign-in'}
method="sms"
value={fieldValue.phone}
onClose={onModalCloseHandler}
/>
</>
<input hidden type="submit" />
</form>
);
};

View file

@ -47,11 +47,11 @@ const ResetPassword = ({ className, autoFocus }: Props) => {
const resetPasswordErrorHandlers: ErrorHandlers = useMemo(
() => ({
'session.forgot_password_session_not_found': async (error) => {
'session.verification_session_not_found': async (error) => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);
},
'session.forgot_password_verification_expired': async (error) => {
'session.verification_expired': async (error) => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);
},