diff --git a/packages/core/src/routes/session/consts.ts b/packages/core/src/routes/session/consts.ts index 8b88eec03..faef71180 100644 --- a/packages/core/src/routes/session/consts.ts +++ b/packages/core/src/routes/session/consts.ts @@ -1 +1 @@ -export const forgotPasswordVerificationTimeout = 10 * 60; // 10 mins. +export const verificationTimeout = 10 * 60; // 10 mins. diff --git a/packages/core/src/routes/session/forgot-password.test.ts b/packages/core/src/routes/session/forgot-password.test.ts index 8ceec189b..83c2816c1 100644 --- a/packages/core/src/routes/session/forgot-password.test.ts +++ b/packages/core/src/routes/session/forgot-password.test.ts @@ -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 => 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({ diff --git a/packages/core/src/routes/session/forgot-password.ts b/packages/core/src/routes/session/forgot-password.ts index fb3e11d11..7942bacf7 100644 --- a/packages/core/src/routes/session/forgot-password.ts +++ b/packages/core/src/routes/session/forgot-password.ts @@ -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( 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( 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(); diff --git a/packages/core/src/routes/session/middleware/passwordless-action.ts b/packages/core/src/routes/session/middleware/passwordless-action.ts new file mode 100644 index 000000000..bf54023d3 --- /dev/null +++ b/packages/core/src/routes/session/middleware/passwordless-action.ts @@ -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 = ( + provider: Provider +): MiddlewareType => { + 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 = ( + provider: Provider +): MiddlewareType => { + 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 = ( + provider: Provider +): MiddlewareType => { + 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 = ( + provider: Provider +): MiddlewareType => { + 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(); + }; +}; diff --git a/packages/core/src/routes/session/passwordless.test.ts b/packages/core/src/routes/session/passwordless.test.ts index afca44857..bb42252ca 100644 --- a/packages/core/src/routes/session/passwordless.test.ts +++ b/packages/core/src/routes/session/passwordless.test.ts @@ -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 */ diff --git a/packages/core/src/routes/session/passwordless.ts b/packages/core/src/routes/session/passwordless.ts index ec7b9a0d2..b6778d60c 100644 --- a/packages/core/src/routes/session/passwordless.ts +++ b/packages/core/src/routes/session/passwordless.ts @@ -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( 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( ); 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( ); 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)); } diff --git a/packages/core/src/routes/session/types.ts b/packages/core/src/routes/session/types.ts new file mode 100644 index 000000000..7cf71da4b --- /dev/null +++ b/packages/core/src/routes/session/types.ts @@ -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; + +export const operationGuard = z.enum(['send', 'verify']); + +export type Operation = z.infer; + +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; + +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; + +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; + +export const forgotPasswordSessionResultGuard = z.object({ + verification: forgotPasswordSessionStorageGuard, +}); + +export type VerificationStorage = + | SmsSessionStorage + | EmailSessionStorage + | ForgotPasswordSessionStorage; + +export type VerificationResult = { verification: T }; diff --git a/packages/core/src/routes/session/utils.ts b/packages/core/src/routes/session/utils.ts index 5aa28665f..371e3b91b 100644 --- a/packages/core/src/routes/session/utils.ts +++ b/packages/core/src/routes/session/utils.ts @@ -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 ( + ctx: Context, + provider: Provider, + resultGuard: ZodType> +): Promise => { + 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 extends unknown ? Omit : never; + +export const assignVerificationResult = async ( + ctx: Context, + provider: Provider, + verificationData: DistributiveOmit +) => { + 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); + } +}; diff --git a/packages/integration-tests/src/api/session.ts b/packages/integration-tests/src/api/session.ts index b0b637f51..1e8a90846 100644 --- a/packages/integration-tests/src/api/session.ts +++ b/packages/integration-tests/src/api/session.ts @@ -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(); 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(); + +export const checkVerificationSessionAndRegisterWithEmail = (interactionCookie: string) => + api + .post('session/register/passwordless/email', { + headers: { + cookie: interactionCookie, }, }) .json(); 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(); + +export const checkVerificationSessionAndSignInWithEmail = (interactionCookie: string) => + api + .post('session/sign-in/passwordless/email', { + headers: { + cookie: interactionCookie, }, }) .json(); 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(); + +export const checkVerificationSessionAndRegisterWithSms = (interactionCookie: string) => + api + .post('session/register/passwordless/sms', { + headers: { + cookie: interactionCookie, }, }) .json(); 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(); + +export const checkVerificationSessionAndSignInWithSms = (interactionCookie: string) => + api + .post('session/sign-in/passwordless/sms', { + headers: { + cookie: interactionCookie, }, }) .json(); diff --git a/packages/phrases-ui/src/locales/en.ts b/packages/phrases-ui/src/locales/en.ts index 7098aee13..a69926ca7 100644 --- a/packages/phrases-ui/src/locales/en.ts +++ b/packages/phrases-ui/src/locales/en.ts @@ -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.', diff --git a/packages/phrases-ui/src/locales/fr.ts b/packages/phrases-ui/src/locales/fr.ts index e9cf11712..5e3735ca5 100644 --- a/packages/phrases-ui/src/locales/fr.ts +++ b/packages/phrases-ui/src/locales/fr.ts @@ -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: diff --git a/packages/phrases-ui/src/locales/ko-kr.ts b/packages/phrases-ui/src/locales/ko-kr.ts index 841a8683e..2c09f0507 100644 --- a/packages/phrases-ui/src/locales/ko-kr.ts +++ b/packages/phrases-ui/src/locales/ko-kr.ts @@ -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: '계정이 이미 있으신가요? 로그인하여 다른 계정과 연동해보세요.', diff --git a/packages/phrases-ui/src/locales/pt-pt.ts b/packages/phrases-ui/src/locales/pt-pt.ts index 628a53a28..2a87c596f 100644 --- a/packages/phrases-ui/src/locales/pt-pt.ts +++ b/packages/phrases-ui/src/locales/pt-pt.ts @@ -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.', diff --git a/packages/phrases-ui/src/locales/tr-tr.ts b/packages/phrases-ui/src/locales/tr-tr.ts index 5728c1794..7fee4ac22 100644 --- a/packages/phrases-ui/src/locales/tr-tr.ts +++ b/packages/phrases-ui/src/locales/tr-tr.ts @@ -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.', diff --git a/packages/phrases-ui/src/locales/zh-cn.ts b/packages/phrases-ui/src/locales/zh-cn.ts index d615e602f..ec242f1e0 100644 --- a/packages/phrases-ui/src/locales/zh-cn.ts +++ b/packages/phrases-ui/src/locales/zh-cn.ts @@ -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: '已有帐号?登录以绑定社交身份。', diff --git a/packages/phrases/src/locales/en/errors.ts b/packages/phrases/src/locales/en/errors.ts index 88f38ee7e..775681b35 100644 --- a/packages/phrases/src/locales/en/errors.ts +++ b/packages/phrases/src/locales/en/errors.ts @@ -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 can’t 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; diff --git a/packages/phrases/src/locales/fr/errors.ts b/packages/phrases/src/locales/fr/errors.ts index 57cd4a168..936f79c17 100644 --- a/packages/phrases/src/locales/fr/errors.ts +++ b/packages/phrases/src/locales/fr/errors.ts @@ -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 can’t 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; diff --git a/packages/phrases/src/locales/ko-kr/errors.ts b/packages/phrases/src/locales/ko-kr/errors.ts index 132737bd8..ca0eab2dd 100644 --- a/packages/phrases/src/locales/ko-kr/errors.ts +++ b/packages/phrases/src/locales/ko-kr/errors.ts @@ -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 can’t 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; diff --git a/packages/phrases/src/locales/pt-pt/errors.ts b/packages/phrases/src/locales/pt-pt/errors.ts index aef33f36b..46afcadbb 100644 --- a/packages/phrases/src/locales/pt-pt/errors.ts +++ b/packages/phrases/src/locales/pt-pt/errors.ts @@ -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 can’t 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; diff --git a/packages/phrases/src/locales/tr-tr/errors.ts b/packages/phrases/src/locales/tr-tr/errors.ts index d9ac01bcc..2cdaf3ca9 100644 --- a/packages/phrases/src/locales/tr-tr/errors.ts +++ b/packages/phrases/src/locales/tr-tr/errors.ts @@ -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 can’t 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; diff --git a/packages/phrases/src/locales/zh-cn/errors.ts b/packages/phrases/src/locales/zh-cn/errors.ts index 963046576..28629dc3a 100644 --- a/packages/phrases/src/locales/zh-cn/errors.ts +++ b/packages/phrases/src/locales/zh-cn/errors.ts @@ -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; diff --git a/packages/ui/src/apis/forgot-password.ts b/packages/ui/src/apis/forgot-password.ts index d464233e2..c4c4b00d6 100644 --- a/packages/ui/src/apis/forgot-password.ts +++ b/packages/ui/src/apis/forgot-password.ts @@ -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(); + .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(); + .json(); return { success: true }; }; diff --git a/packages/ui/src/apis/index.test.ts b/packages/ui/src/apis/index.test.ts index 405992ef5..2e625a86b 100644 --- a/packages/ui/src/apis/index.test.ts +++ b/packages/ui/src/apis/index.test.ts @@ -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, }, }); }); diff --git a/packages/ui/src/apis/register.ts b/packages/ui/src/apis/register.ts index 029230373..d2e80aed1 100644 --- a/packages/ui/src/apis/register.ts +++ b/packages/ui/src/apis/register.ts @@ -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(); }; +export const registerWithSms = async () => + api.post(`${apiPrefix}/register/passwordless/sms`).json(); + +export const registerWithEmail = async () => + api.post(`${apiPrefix}/register/passwordless/email`).json(); + 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(); -}; 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(); -}; diff --git a/packages/ui/src/apis/sign-in.ts b/packages/ui/src/apis/sign-in.ts index 537598c59..92e216d1b 100644 --- a/packages/ui/src/apis/sign-in.ts +++ b/packages/ui/src/apis/sign-in.ts @@ -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(); + + 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(); + + 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(); @@ -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(); diff --git a/packages/ui/src/containers/PasscodeValidation/index.tsx b/packages/ui/src/containers/PasscodeValidation/index.tsx index edcfa4913..0a0c8bd58 100644 --- a/packages/ui/src/containers/PasscodeValidation/index.tsx +++ b/packages/ui/src/containers/PasscodeValidation/index.tsx @@ -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(); 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( diff --git a/packages/ui/src/containers/PasscodeValidation/use-forgot-password-with-email-error-handler.ts b/packages/ui/src/containers/PasscodeValidation/use-forgot-password-with-email-error-handler.ts new file mode 100644 index 000000000..8e0bb2067 --- /dev/null +++ b/packages/ui/src/containers/PasscodeValidation/use-forgot-password-with-email-error-handler.ts @@ -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( + () => ({ + 'user.email_not_exists': emailNotExistForgotPasswordHandler, + }), + [emailNotExistForgotPasswordHandler] + ); + + return { + errorHandler, + }; +}; + +export default useForgotPasswordWithEmailErrorHandler; diff --git a/packages/ui/src/containers/PasscodeValidation/use-forgot-password-with-sms-error-handler.ts b/packages/ui/src/containers/PasscodeValidation/use-forgot-password-with-sms-error-handler.ts new file mode 100644 index 000000000..aa8461d11 --- /dev/null +++ b/packages/ui/src/containers/PasscodeValidation/use-forgot-password-with-sms-error-handler.ts @@ -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( + () => ({ + 'user.phone_not_exists': phoneNotExistForgotPasswordHandler, + }), + [phoneNotExistForgotPasswordHandler] + ); + + return { + errorHandler, + }; +}; + +export default useForgotPasswordWithSmsErrorHandler; diff --git a/packages/ui/src/containers/PasscodeValidation/use-passcode-validation-error-handler.ts b/packages/ui/src/containers/PasscodeValidation/use-passcode-validation-error-handler.ts new file mode 100644 index 000000000..1454f66c3 --- /dev/null +++ b/packages/ui/src/containers/PasscodeValidation/use-passcode-validation-error-handler.ts @@ -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; diff --git a/packages/ui/src/containers/PasscodeValidation/use-register-with-sms-error-handler.ts b/packages/ui/src/containers/PasscodeValidation/use-register-with-sms-error-handler.ts new file mode 100644 index 000000000..66d72f737 --- /dev/null +++ b/packages/ui/src/containers/PasscodeValidation/use-register-with-sms-error-handler.ts @@ -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( + () => ({ + 'user.phone_exists_register': async () => { + await phoneExistRegisterHandler(); + }, + }), + [phoneExistRegisterHandler] + ); + + return { + errorHandler, + }; +}; + +export default useRegisterWithSmsErrorHandler; diff --git a/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-email-error-handler.ts b/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-email-error-handler.ts new file mode 100644 index 000000000..7fa92996a --- /dev/null +++ b/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-email-error-handler.ts @@ -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( + () => ({ + '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; diff --git a/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-sms-error-handler.ts b/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-sms-error-handler.ts new file mode 100644 index 000000000..615a1c221 --- /dev/null +++ b/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-sms-error-handler.ts @@ -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( + () => ({ + '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; diff --git a/packages/ui/src/containers/PasscodeValidation/user-register-with-email-error-handler.ts b/packages/ui/src/containers/PasscodeValidation/user-register-with-email-error-handler.ts new file mode 100644 index 000000000..b38e382a3 --- /dev/null +++ b/packages/ui/src/containers/PasscodeValidation/user-register-with-email-error-handler.ts @@ -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( + () => ({ + 'user.email_exists_register': async () => { + await emailExistRegisterHandler(); + }, + }), + [emailExistRegisterHandler] + ); + + return { + errorHandler, + }; +}; + +export default useRegisterWithEmailErrorHandler; diff --git a/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx b/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx index 3eb44685a..7d34d4d3e 100644 --- a/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx +++ b/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx @@ -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 ( - <> -
-
- { - setFieldValue((state) => ({ ...state, email: '' })); - }} - /> - {hasSwitch && } -
+ +
+ { + setFieldValue((state) => ({ ...state, email: '' })); + }} + /> + {hasSwitch && } +
- {hasTerms && } -