From d641e201c2a9963ba808429aa56191cc4b8ab492 Mon Sep 17 00:00:00 2001
From: simeng-li <simeng@silverhand.io>
Date: Wed, 28 Dec 2022 11:53:13 +0800
Subject: [PATCH] refactor(core): remove the event from sendPasscode API
 payload (#2742)

---
 .../core/src/routes/interaction/index.test.ts |  12 +-
 packages/core/src/routes/interaction/index.ts |  27 ++-
 .../src/routes/interaction/types/guard.ts     |   2 -
 .../utils/passcode-validation.test.ts         |   4 +-
 .../interaction/utils/passcode-validation.ts  |   2 +-
 .../user-identity-verification.test.ts        |   2 +-
 .../user-identity-verification.ts             |   2 +-
 .../integration-tests/src/api/interaction.ts  |   3 +-
 .../api/interaction/forgot-password.test.ts   |   2 -
 .../register-with-identifier.test.ts          |   6 -
 .../sign-in-with-passcode-identifier.test.ts  |   7 -
 .../sign-in-with-password-identifier.test.ts  |   2 -
 packages/ui/src/apis/continue.test.ts         |  55 +----
 packages/ui/src/apis/continue.ts              |  49 -----
 packages/ui/src/apis/forgot-password.ts       |  73 -------
 packages/ui/src/apis/index.test.ts            | 205 ------------------
 packages/ui/src/apis/interaction.ts           |  90 ++++++++
 packages/ui/src/apis/register.ts              |  63 ------
 packages/ui/src/apis/sign-in.ts               | 100 ---------
 packages/ui/src/apis/utils.ts                 |  47 +---
 .../EmailForm/EmailContinue.test.tsx          |  10 +-
 .../containers/EmailForm/EmailForm.test.tsx   |   4 +-
 .../ui/src/containers/EmailForm/EmailForm.tsx |   6 +-
 .../EmailForm/EmailRegister.test.tsx          |  11 +-
 .../EmailForm/EmailResetPassword.test.tsx     |  12 +-
 .../containers/EmailForm/EmailSignIn.test.tsx |  25 ++-
 .../PasscodeValidation/index.test.tsx         | 119 +++++-----
 ...-continue-set-email-passcode-validation.ts |  37 ++--
 ...se-continue-set-sms-passcode-validation.ts |  38 ++--
 ...rgot-password-email-passcode-validation.ts |  18 +-
 ...forgot-password-sms-passcode-validation.ts |  19 +-
 ...register-with-email-passcode-validation.ts |  14 +-
 ...e-register-with-sms-passcode-validation.ts |  17 +-
 .../PasscodeValidation/use-resend-passcode.ts |   9 +-
 ...-sign-in-with-email-passcode-validation.ts |  27 ++-
 ...se-sign-in-with-sms-passcode-validation.ts |  25 ++-
 .../PasswordlessSignInLink.tsx                |   4 +-
 .../PasswordSignInForm/index.test.tsx         |  18 +-
 .../containers/PhoneForm/PhoneForm.test.tsx   |   4 +-
 .../ui/src/containers/PhoneForm/PhoneForm.tsx |   6 +-
 .../containers/PhoneForm/SmsContinue.test.tsx |  10 +-
 .../containers/PhoneForm/SmsRegister.test.tsx |  11 +-
 .../PhoneForm/SmsResetPassword.test.tsx       |  12 +-
 .../containers/PhoneForm/SmsSignIn.test.tsx   |  21 +-
 .../use-continue-sign-in-with-password.ts     |  12 +-
 .../src/hooks/use-passwordless-send-code.ts   |  14 +-
 .../use-username-password-register.ts         |   2 +-
 .../ui/src/pages/ResetPassword/index.test.tsx |   8 +-
 .../pages/ResetPassword/use-reset-password.ts |  10 +-
 .../pages/SecondaryRegister/index.test.tsx    |   1 -
 .../src/pages/SecondarySignIn/index.test.tsx  |   1 -
 51 files changed, 418 insertions(+), 860 deletions(-)
 delete mode 100644 packages/ui/src/apis/forgot-password.ts
 delete mode 100644 packages/ui/src/apis/register.ts
 delete mode 100644 packages/ui/src/apis/sign-in.ts

diff --git a/packages/core/src/routes/interaction/index.test.ts b/packages/core/src/routes/interaction/index.test.ts
index f9eb5c41e..12a25b914 100644
--- a/packages/core/src/routes/interaction/index.test.ts
+++ b/packages/core/src/routes/interaction/index.test.ts
@@ -78,7 +78,7 @@ const { storeInteractionResult, mergeIdentifiers, getInteractionStorage } = awai
   () => ({
     mergeIdentifiers: jest.fn(),
     storeInteractionResult: jest.fn(),
-    getInteractionStorage: jest.fn().mockResolvedValue({
+    getInteractionStorage: jest.fn().mockReturnValue({
       event: InteractionEvent.SignIn,
     }),
   })
@@ -262,13 +262,19 @@ describe('session -> interactionRoutes', () => {
 
     it('should call send passcode properly', async () => {
       const body = {
-        event: InteractionEvent.SignIn,
         email: 'email@logto.io',
       };
 
       const response = await sessionRequest.post(path).send(body);
       expect(getInteractionStorage).toBeCalled();
-      expect(sendPasscodeToIdentifier).toBeCalledWith(body, 'jti', createLog);
+      expect(sendPasscodeToIdentifier).toBeCalledWith(
+        {
+          event: InteractionEvent.SignIn,
+          ...body,
+        },
+        'jti',
+        createLog
+      );
       expect(response.status).toEqual(204);
     });
   });
diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts
index b5a085356..ef438e6fa 100644
--- a/packages/core/src/routes/interaction/index.ts
+++ b/packages/core/src/routes/interaction/index.ts
@@ -70,11 +70,11 @@ export default function interactionRoutes<T extends AnonymousRouter>(
 
       verifySignInModeSettings(event, signInExperience);
 
-      if (identifier) {
+      if (identifier && event !== InteractionEvent.ForgotPassword) {
         verifyIdentifierSettings(identifier, signInExperience);
       }
 
-      if (profile) {
+      if (profile && event !== InteractionEvent.ForgotPassword) {
         verifyProfileSettings(profile, signInExperience);
       }
 
@@ -145,10 +145,12 @@ export default function interactionRoutes<T extends AnonymousRouter>(
     async (ctx, next) => {
       const identifierPayload = ctx.guard.body;
       const { signInExperience, interactionDetails } = ctx;
-      verifyIdentifierSettings(identifierPayload, signInExperience);
-
       const interactionStorage = getInteractionStorage(interactionDetails.result);
 
+      if (interactionStorage.event === InteractionEvent.ForgotPassword) {
+        verifyIdentifierSettings(identifierPayload, signInExperience);
+      }
+
       const verifiedIdentifier = await verifyIdentifierPayload(
         ctx,
         provider,
@@ -175,12 +177,14 @@ export default function interactionRoutes<T extends AnonymousRouter>(
     koaInteractionSie(),
     async (ctx, next) => {
       const profilePayload = ctx.guard.body;
-
       const { signInExperience, interactionDetails } = ctx;
-      verifyProfileSettings(profilePayload, signInExperience);
 
       // Check interaction exists
-      getInteractionStorage(interactionDetails.result);
+      const { event } = getInteractionStorage(interactionDetails.result);
+
+      if (event !== InteractionEvent.ForgotPassword) {
+        verifyProfileSettings(profilePayload, signInExperience);
+      }
 
       await storeInteractionResult(
         {
@@ -207,10 +211,13 @@ export default function interactionRoutes<T extends AnonymousRouter>(
     async (ctx, next) => {
       const profilePayload = ctx.guard.body;
       const { signInExperience, interactionDetails } = ctx;
-      verifyProfileSettings(profilePayload, signInExperience);
 
       const interactionStorage = getInteractionStorage(interactionDetails.result);
 
+      if (interactionStorage.event !== InteractionEvent.ForgotPassword) {
+        verifyProfileSettings(profilePayload, signInExperience);
+      }
+
       await storeInteractionResult(
         {
           profile: {
@@ -292,9 +299,9 @@ export default function interactionRoutes<T extends AnonymousRouter>(
     async (ctx, next) => {
       const { interactionDetails, guard, createLog } = ctx;
       // Check interaction exists
-      getInteractionStorage(interactionDetails.result);
+      const { event } = getInteractionStorage(interactionDetails.result);
 
-      await sendPasscodeToIdentifier(guard.body, interactionDetails.jti, createLog);
+      await sendPasscodeToIdentifier({ event, ...guard.body }, interactionDetails.jti, createLog);
 
       ctx.status = 204;
 
diff --git a/packages/core/src/routes/interaction/types/guard.ts b/packages/core/src/routes/interaction/types/guard.ts
index 2e9ddca41..bf328af60 100644
--- a/packages/core/src/routes/interaction/types/guard.ts
+++ b/packages/core/src/routes/interaction/types/guard.ts
@@ -7,11 +7,9 @@ import { socialUserInfoGuard } from '#src/connectors/types.js';
 // Passcode Send Route Payload Guard
 export const sendPasscodePayloadGuard = z.union([
   z.object({
-    event: eventGuard,
     email: z.string().regex(emailRegEx),
   }),
   z.object({
-    event: eventGuard,
     phone: z.string().regex(phoneRegEx),
   }),
 ]);
diff --git a/packages/core/src/routes/interaction/utils/passcode-validation.test.ts b/packages/core/src/routes/interaction/utils/passcode-validation.test.ts
index 41bbc442e..845e4b68f 100644
--- a/packages/core/src/routes/interaction/utils/passcode-validation.test.ts
+++ b/packages/core/src/routes/interaction/utils/passcode-validation.test.ts
@@ -4,8 +4,6 @@ import { createMockUtils } from '@logto/shared/esm';
 
 import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
 
-import type { SendPasscodePayload } from '../types/index.js';
-
 const { jest } = import.meta;
 const { mockEsmWithActual } = createMockUtils(jest);
 
@@ -55,7 +53,7 @@ describe('passcode-validation utils', () => {
   it.each(sendPasscodeTestCase)(
     'send passcode successfully',
     async ({ payload, createPasscodeParams }) => {
-      await sendPasscodeToIdentifier(payload as SendPasscodePayload, 'jti', log.createLog);
+      await sendPasscodeToIdentifier(payload, 'jti', log.createLog);
       expect(passcode.createPasscode).toBeCalledWith('jti', ...createPasscodeParams);
       expect(passcode.sendPasscode).toBeCalled();
     }
diff --git a/packages/core/src/routes/interaction/utils/passcode-validation.ts b/packages/core/src/routes/interaction/utils/passcode-validation.ts
index 104c6d1c2..f74e85714 100644
--- a/packages/core/src/routes/interaction/utils/passcode-validation.ts
+++ b/packages/core/src/routes/interaction/utils/passcode-validation.ts
@@ -20,7 +20,7 @@ const getMessageTypesByEvent = (event: InteractionEvent): MessageTypes =>
   eventToMessageTypesMap[event];
 
 export const sendPasscodeToIdentifier = async (
-  payload: SendPasscodePayload,
+  payload: SendPasscodePayload & { event: InteractionEvent },
   jti: string,
   createLog: LogContext['createLog']
 ) => {
diff --git a/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts b/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts
index dbbb27041..e6f389f7f 100644
--- a/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts
+++ b/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts
@@ -153,7 +153,7 @@ describe('verifyUserAccount', () => {
     };
 
     await expect(verifyUserAccount(interaction)).rejects.toMatchError(
-      new RequestError({ code: 'user.user_not_exist', status: 404 }, { identifier: 'email' })
+      new RequestError({ code: 'user.user_not_exist', status: 404 }, { identity: 'email' })
     );
 
     expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' });
diff --git a/packages/core/src/routes/interaction/verifications/user-identity-verification.ts b/packages/core/src/routes/interaction/verifications/user-identity-verification.ts
index f1c31a28b..fc9d514ef 100644
--- a/packages/core/src/routes/interaction/verifications/user-identity-verification.ts
+++ b/packages/core/src/routes/interaction/verifications/user-identity-verification.ts
@@ -26,7 +26,7 @@ const identifyUserByVerifiedEmailOrPhone = async (
 
   assertThat(
     user,
-    new RequestError({ code: 'user.user_not_exist', status: 404 }, { identifier: identifier.value })
+    new RequestError({ code: 'user.user_not_exist', status: 404 }, { identity: identifier.value })
   );
 
   const { id, isSuspended } = user;
diff --git a/packages/integration-tests/src/api/interaction.ts b/packages/integration-tests/src/api/interaction.ts
index 2db50c393..e94b4fcda 100644
--- a/packages/integration-tests/src/api/interaction.ts
+++ b/packages/integration-tests/src/api/interaction.ts
@@ -68,10 +68,9 @@ export const submitInteraction = async (cookie: string) =>
 
 export type VerificationPasscodePayload =
   | {
-      event: InteractionEvent;
       email: string;
     }
-  | { event: InteractionEvent; phone: string };
+  | { phone: string };
 
 export const sendVerificationPasscode = async (
   cookie: string,
diff --git a/packages/integration-tests/src/tests/api/interaction/forgot-password.test.ts b/packages/integration-tests/src/tests/api/interaction/forgot-password.test.ts
index 91210122b..3e848f137 100644
--- a/packages/integration-tests/src/tests/api/interaction/forgot-password.test.ts
+++ b/packages/integration-tests/src/tests/api/interaction/forgot-password.test.ts
@@ -42,7 +42,6 @@ describe('reset password', () => {
 
     await client.successSend(putInteraction, { event: InteractionEvent.ForgotPassword });
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.ForgotPassword,
       email: userProfile.primaryEmail,
     });
 
@@ -96,7 +95,6 @@ describe('reset password', () => {
 
     await client.successSend(putInteraction, { event: InteractionEvent.ForgotPassword });
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.ForgotPassword,
       phone: userProfile.primaryPhone,
     });
 
diff --git a/packages/integration-tests/src/tests/api/interaction/register-with-identifier.test.ts b/packages/integration-tests/src/tests/api/interaction/register-with-identifier.test.ts
index 9007be638..811f27379 100644
--- a/packages/integration-tests/src/tests/api/interaction/register-with-identifier.test.ts
+++ b/packages/integration-tests/src/tests/api/interaction/register-with-identifier.test.ts
@@ -73,7 +73,6 @@ describe('Register with passwordless identifier', () => {
     });
 
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.Register,
       email: primaryEmail,
     });
 
@@ -120,7 +119,6 @@ describe('Register with passwordless identifier', () => {
     });
 
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.Register,
       email: primaryEmail,
     });
 
@@ -179,7 +177,6 @@ describe('Register with passwordless identifier', () => {
     });
 
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.Register,
       phone: primaryPhone,
     });
 
@@ -226,7 +223,6 @@ describe('Register with passwordless identifier', () => {
     });
 
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.Register,
       phone: primaryPhone,
     });
 
@@ -288,7 +284,6 @@ describe('Register with passwordless identifier', () => {
     });
 
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.Register,
       email: primaryEmail,
     });
 
@@ -341,7 +336,6 @@ describe('Register with passwordless identifier', () => {
     });
 
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.Register,
       phone: primaryPhone,
     });
 
diff --git a/packages/integration-tests/src/tests/api/interaction/sign-in-with-passcode-identifier.test.ts b/packages/integration-tests/src/tests/api/interaction/sign-in-with-passcode-identifier.test.ts
index b705490ae..952402cfe 100644
--- a/packages/integration-tests/src/tests/api/interaction/sign-in-with-passcode-identifier.test.ts
+++ b/packages/integration-tests/src/tests/api/interaction/sign-in-with-passcode-identifier.test.ts
@@ -37,7 +37,6 @@ describe('Sign-In flow using passcode identifiers', () => {
     });
 
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.SignIn,
       email: userProfile.primaryEmail,
     });
 
@@ -71,7 +70,6 @@ describe('Sign-In flow using passcode identifiers', () => {
     });
 
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.SignIn,
       phone: userProfile.primaryPhone,
     });
 
@@ -111,7 +109,6 @@ describe('Sign-In flow using passcode identifiers', () => {
     });
 
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.SignIn,
       email: newEmail,
     });
 
@@ -151,7 +148,6 @@ describe('Sign-In flow using passcode identifiers', () => {
     });
 
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.SignIn,
       phone: newPhone,
     });
 
@@ -197,7 +193,6 @@ describe('Sign-In flow using passcode identifiers', () => {
     });
 
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.SignIn,
       email: userProfile.primaryEmail,
     });
     const { code } = await readPasscode();
@@ -257,7 +252,6 @@ describe('Sign-In flow using passcode identifiers', () => {
     });
 
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.SignIn,
       email: userProfile.primaryEmail,
     });
     const { code } = await readPasscode();
@@ -309,7 +303,6 @@ describe('Sign-In flow using passcode identifiers', () => {
     });
 
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.SignIn,
       email: userProfile.primaryEmail,
     });
     const { code } = await readPasscode();
diff --git a/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier.test.ts b/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier.test.ts
index 5216a19e7..1f29fd2da 100644
--- a/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier.test.ts
+++ b/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier.test.ts
@@ -112,7 +112,6 @@ describe('Sign-In flow using password identifiers', () => {
     await expectRejects(client.submitInteraction(), 'user.missing_profile');
 
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.SignIn,
       email: primaryEmail,
     });
 
@@ -172,7 +171,6 @@ describe('Sign-In flow using password identifiers', () => {
     await expectRejects(client.submitInteraction(), 'user.missing_profile');
 
     await client.successSend(sendVerificationPasscode, {
-      event: InteractionEvent.SignIn,
       phone: primaryPhone,
     });
 
diff --git a/packages/ui/src/apis/continue.test.ts b/packages/ui/src/apis/continue.test.ts
index b4bf7e5c0..be9ba0caa 100644
--- a/packages/ui/src/apis/continue.test.ts
+++ b/packages/ui/src/apis/continue.test.ts
@@ -1,13 +1,6 @@
-import { MessageTypes } from '@logto/connector-kit';
 import ky from 'ky';
 
-import {
-  continueApi,
-  sendContinueSetEmailPasscode,
-  sendContinueSetPhonePasscode,
-  verifyContinueSetEmailPasscode,
-  verifyContinueSetSmsPasscode,
-} from './continue';
+import { continueApi } from './continue';
 
 jest.mock('ky', () => ({
   extend: () => ky,
@@ -68,50 +61,4 @@ describe('continue API', () => {
       },
     });
   });
-
-  it('sendContinueSetEmailPasscode', async () => {
-    await sendContinueSetEmailPasscode('email');
-
-    expect(ky.post).toBeCalledWith('/api/session/passwordless/email/send', {
-      json: {
-        email: 'email',
-        flow: MessageTypes.Continue,
-      },
-    });
-  });
-
-  it('sendContinueSetSmsPasscode', async () => {
-    await sendContinueSetPhonePasscode('111111');
-
-    expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/send', {
-      json: {
-        phone: '111111',
-        flow: MessageTypes.Continue,
-      },
-    });
-  });
-
-  it('verifyContinueSetEmailPasscode', async () => {
-    await verifyContinueSetEmailPasscode('email', 'passcode');
-
-    expect(ky.post).toBeCalledWith('/api/session/passwordless/email/verify', {
-      json: {
-        email: 'email',
-        code: 'passcode',
-        flow: MessageTypes.Continue,
-      },
-    });
-  });
-
-  it('verifyContinueSetSmsPasscode', async () => {
-    await verifyContinueSetSmsPasscode('phone', 'passcode');
-
-    expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/verify', {
-      json: {
-        phone: 'phone',
-        code: 'passcode',
-        flow: MessageTypes.Continue,
-      },
-    });
-  });
 });
diff --git a/packages/ui/src/apis/continue.ts b/packages/ui/src/apis/continue.ts
index eef440e61..52294532b 100644
--- a/packages/ui/src/apis/continue.ts
+++ b/packages/ui/src/apis/continue.ts
@@ -1,5 +1,3 @@
-import { MessageTypes } from '@logto/connector-kit';
-
 import api from './api';
 import { bindSocialAccount } from './social';
 
@@ -7,7 +5,6 @@ type Response = {
   redirectTo: string;
 };
 
-const passwordlessApiPrefix = '/api/session/passwordless';
 const continueApiPrefix = '/api/session/sign-in/continue';
 
 type ContinueKey = 'password' | 'username' | 'email' | 'phone';
@@ -25,49 +22,3 @@ export const continueApi = async (key: ContinueKey, value: string, socialToBind?
 
   return result;
 };
-
-export const sendContinueSetEmailPasscode = async (email: string) => {
-  await api
-    .post(`${passwordlessApiPrefix}/email/send`, {
-      json: {
-        email,
-        flow: MessageTypes.Continue,
-      },
-    })
-    .json();
-
-  return { success: true };
-};
-
-export const sendContinueSetPhonePasscode = async (phone: string) => {
-  await api
-    .post(`${passwordlessApiPrefix}/sms/send`, {
-      json: {
-        phone,
-        flow: MessageTypes.Continue,
-      },
-    })
-    .json();
-
-  return { success: true };
-};
-
-export const verifyContinueSetEmailPasscode = async (email: string, code: string) => {
-  await api
-    .post(`${passwordlessApiPrefix}/email/verify`, {
-      json: { email, code, flow: MessageTypes.Continue },
-    })
-    .json();
-
-  return { success: true };
-};
-
-export const verifyContinueSetSmsPasscode = async (phone: string, code: string) => {
-  await api
-    .post(`${passwordlessApiPrefix}/sms/verify`, {
-      json: { phone, code, flow: MessageTypes.Continue },
-    })
-    .json();
-
-  return { success: true };
-};
diff --git a/packages/ui/src/apis/forgot-password.ts b/packages/ui/src/apis/forgot-password.ts
deleted file mode 100644
index 752d8c1eb..000000000
--- a/packages/ui/src/apis/forgot-password.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { MessageTypes } from '@logto/connector-kit';
-
-import api from './api';
-
-type Response = {
-  redirectTo: string;
-};
-
-const forgotPasswordApiPrefix = '/api/session/forgot-password';
-
-export const sendForgotPasswordSmsPasscode = async (phone: string) => {
-  await api
-    .post('/api/session/passwordless/sms/send', {
-      json: {
-        phone,
-        flow: MessageTypes.ForgotPassword,
-      },
-    })
-    .json();
-
-  return { success: true };
-};
-
-export const verifyForgotPasswordSmsPasscode = async (phone: string, code: string) => {
-  await api
-    .post('/api/session/passwordless/sms/verify', {
-      json: {
-        phone,
-        code,
-        flow: MessageTypes.ForgotPassword,
-      },
-    })
-    .json();
-
-  return { success: true };
-};
-
-export const sendForgotPasswordEmailPasscode = async (email: string) => {
-  await api
-    .post('/api/session/passwordless/email/send', {
-      json: {
-        email,
-        flow: MessageTypes.ForgotPassword,
-      },
-    })
-    .json();
-
-  return { success: true };
-};
-
-export const verifyForgotPasswordEmailPasscode = async (email: string, code: string) => {
-  await api
-    .post('/api/session/passwordless/email/verify', {
-      json: {
-        email,
-        code,
-        flow: MessageTypes.ForgotPassword,
-      },
-    })
-    .json();
-
-  return { success: true };
-};
-
-export const resetPassword = async (password: string) => {
-  await api
-    .post(`${forgotPasswordApiPrefix}/reset`, {
-      json: { password },
-    })
-    .json<Response>();
-
-  return { success: true };
-};
diff --git a/packages/ui/src/apis/index.test.ts b/packages/ui/src/apis/index.test.ts
index f61d7670e..8ffefd937 100644
--- a/packages/ui/src/apis/index.test.ts
+++ b/packages/ui/src/apis/index.test.ts
@@ -1,30 +1,6 @@
-import { MessageTypes } from '@logto/connector-kit';
 import ky from 'ky';
 
 import { consent } from './consent';
-import {
-  verifyForgotPasswordEmailPasscode,
-  verifyForgotPasswordSmsPasscode,
-  sendForgotPasswordEmailPasscode,
-  sendForgotPasswordSmsPasscode,
-  resetPassword,
-} from './forgot-password';
-import {
-  registerWithSms,
-  registerWithEmail,
-  sendRegisterEmailPasscode,
-  sendRegisterSmsPasscode,
-  verifyRegisterEmailPasscode,
-  verifyRegisterSmsPasscode,
-} from './register';
-import {
-  signInWithSms,
-  signInWithEmail,
-  sendSignInSmsPasscode,
-  sendSignInEmailPasscode,
-  verifySignInEmailPasscode,
-  verifySignInSmsPasscode,
-} from './sign-in';
 import {
   invokeSocialSignIn,
   signInWithSocial,
@@ -41,8 +17,6 @@ jest.mock('ky', () => ({
 }));
 
 describe('api', () => {
-  const username = 'username';
-  const password = 'password';
   const phone = '18888888';
   const code = '111111';
   const email = 'foo@logto.io';
@@ -53,181 +27,11 @@ describe('api', () => {
     mockKyPost.mockClear();
   });
 
-  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('sendSignInSmsPasscode', async () => {
-    await sendSignInSmsPasscode(phone);
-    expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/send', {
-      json: {
-        phone,
-        flow: MessageTypes.SignIn,
-      },
-    });
-  });
-
-  it('verifySignInSmsPasscode', async () => {
-    mockKyPost.mockReturnValueOnce({
-      json: () => ({
-        redirectTo: '/',
-      }),
-    });
-
-    await verifySignInSmsPasscode(phone, code);
-
-    expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/verify', {
-      json: {
-        phone,
-        code,
-        flow: MessageTypes.SignIn,
-      },
-    });
-  });
-
-  it('sendSignInEmailPasscode', async () => {
-    await sendSignInEmailPasscode(email);
-    expect(ky.post).toBeCalledWith('/api/session/passwordless/email/send', {
-      json: {
-        email,
-        flow: MessageTypes.SignIn,
-      },
-    });
-  });
-
-  it('verifySignInEmailPasscode', async () => {
-    mockKyPost.mockReturnValueOnce({
-      json: () => ({
-        redirectTo: '/',
-      }),
-    });
-
-    await verifySignInEmailPasscode(email, code);
-
-    expect(ky.post).toBeCalledWith('/api/session/passwordless/email/verify', {
-      json: {
-        email,
-        code,
-        flow: MessageTypes.SignIn,
-      },
-    });
-  });
-
   it('consent', async () => {
     await consent();
     expect(ky.post).toBeCalledWith('/api/session/consent');
   });
 
-  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/passwordless/sms/send', {
-      json: {
-        phone,
-        flow: MessageTypes.Register,
-      },
-    });
-  });
-
-  it('verifyRegisterSmsPasscode', async () => {
-    await verifyRegisterSmsPasscode(phone, code);
-    expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/verify', {
-      json: {
-        phone,
-        code,
-        flow: MessageTypes.Register,
-      },
-    });
-  });
-
-  it('sendRegisterEmailPasscode', async () => {
-    await sendRegisterEmailPasscode(email);
-    expect(ky.post).toBeCalledWith('/api/session/passwordless/email/send', {
-      json: {
-        email,
-        flow: MessageTypes.Register,
-      },
-    });
-  });
-
-  it('verifyRegisterEmailPasscode', async () => {
-    await verifyRegisterEmailPasscode(email, code);
-    expect(ky.post).toBeCalledWith('/api/session/passwordless/email/verify', {
-      json: {
-        email,
-        code,
-        flow: MessageTypes.Register,
-      },
-    });
-  });
-
-  it('sendForgotPasswordSmsPasscode', async () => {
-    await sendForgotPasswordSmsPasscode(phone);
-    expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/send', {
-      json: {
-        phone,
-        flow: MessageTypes.ForgotPassword,
-      },
-    });
-  });
-
-  it('verifyForgotPasswordSmsPasscode', async () => {
-    await verifyForgotPasswordSmsPasscode(phone, code);
-    expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/verify', {
-      json: {
-        phone,
-        code,
-        flow: MessageTypes.ForgotPassword,
-      },
-    });
-  });
-
-  it('sendForgotPasswordEmailPasscode', async () => {
-    await sendForgotPasswordEmailPasscode(email);
-    expect(ky.post).toBeCalledWith('/api/session/passwordless/email/send', {
-      json: {
-        email,
-        flow: MessageTypes.ForgotPassword,
-      },
-    });
-  });
-
-  it('verifyForgotPasswordEmailPasscode', async () => {
-    await verifyForgotPasswordEmailPasscode(email, code);
-    expect(ky.post).toBeCalledWith('/api/session/passwordless/email/verify', {
-      json: {
-        email,
-        code,
-        flow: MessageTypes.ForgotPassword,
-      },
-    });
-  });
-
   it('invokeSocialSignIn', async () => {
     await invokeSocialSignIn('connectorId', 'state', 'redirectUri');
     expect(ky.post).toBeCalledWith('/api/session/sign-in/social', {
@@ -279,13 +83,4 @@ describe('api', () => {
       },
     });
   });
-
-  it('resetPassword', async () => {
-    await resetPassword('password');
-    expect(ky.post).toBeCalledWith('/api/session/forgot-password/reset', {
-      json: {
-        password: 'password',
-      },
-    });
-  });
 });
diff --git a/packages/ui/src/apis/interaction.ts b/packages/ui/src/apis/interaction.ts
index 70ad547fd..7abba3c5a 100644
--- a/packages/ui/src/apis/interaction.ts
+++ b/packages/ui/src/apis/interaction.ts
@@ -5,12 +5,15 @@ import type {
   UsernamePasswordPayload,
   EmailPasswordPayload,
   PhonePasswordPayload,
+  EmailPasscodePayload,
+  PhonePasscodePayload,
 } from '@logto/schemas';
 import { conditional } from '@silverhand/essentials';
 
 import api from './api';
 
 const interactionPrefix = '/api/interaction';
+const verificationPath = `verification`;
 
 type Response = {
   redirectTo: string;
@@ -60,5 +63,92 @@ export const setUserPassword = async (password: string) => {
     },
   });
 
+  const result = await api.post(`${interactionPrefix}/submit`).json<Response | undefined>();
+
+  // Reset password does not have any response body
+  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+  return result || { success: true };
+};
+
+export type SendPasscodePayload = { email: string } | { phone: string };
+
+export const putInteraction = async (event: InteractionEvent) =>
+  api.put(`${interactionPrefix}`, { json: { event } });
+
+export const sendPasscode = async (payload: SendPasscodePayload) => {
+  await api.post(`${interactionPrefix}/${verificationPath}/passcode`, { json: payload });
+
+  return { success: true };
+};
+
+export const signInWithPasscodeIdentifier = async (
+  payload: EmailPasscodePayload | PhonePasscodePayload,
+  socialToBind?: string
+) => {
+  await api.patch(`${interactionPrefix}/identifiers`, {
+    json: payload,
+  });
+
+  if (socialToBind) {
+    // TODO: bind social account
+  }
+
+  return api.post(`${interactionPrefix}/submit`).json<Response>();
+};
+
+export const addProfileWithPasscodeIdentifier = async (
+  payload: EmailPasscodePayload | PhonePasscodePayload,
+  socialToBind?: string
+) => {
+  await api.patch(`${interactionPrefix}/identifiers`, {
+    json: payload,
+  });
+
+  const { passcode, ...identifier } = payload;
+
+  await api.patch(`${interactionPrefix}/profile`, {
+    json: identifier,
+  });
+
+  if (socialToBind) {
+    // TODO: bind social account
+  }
+
+  return api.post(`${interactionPrefix}/submit`).json<Response>();
+};
+
+export const verifyForgotPasswordPasscodeIdentifier = async (
+  payload: EmailPasscodePayload | PhonePasscodePayload
+) => {
+  await api.patch(`${interactionPrefix}/identifiers`, {
+    json: payload,
+  });
+
+  return api.post(`${interactionPrefix}/submit`).json<Response>();
+};
+
+export const signInWithVerifierIdentifier = async () => {
+  await api.delete(`${interactionPrefix}/profile`);
+
+  await api.put(`${interactionPrefix}/event`, {
+    json: {
+      event: InteractionEvent.SignIn,
+    },
+  });
+
+  return api.post(`${interactionPrefix}/submit`).json<Response>();
+};
+
+export const registerWithVerifiedIdentifier = async (payload: SendPasscodePayload) => {
+  await api.put(`${interactionPrefix}/event`, {
+    json: {
+      event: InteractionEvent.Register,
+    },
+  });
+
+  await api.put(`${interactionPrefix}/profile`, {
+    json: payload,
+  });
+
   return api.post(`${interactionPrefix}/submit`).json<Response>();
 };
diff --git a/packages/ui/src/apis/register.ts b/packages/ui/src/apis/register.ts
deleted file mode 100644
index 13849429b..000000000
--- a/packages/ui/src/apis/register.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { MessageTypes } from '@logto/connector-kit';
-
-import api from './api';
-
-const apiPrefix = '/api/session';
-
-type Response = {
-  redirectTo: string;
-};
-
-export const registerWithSms = async () =>
-  api.post(`${apiPrefix}/register/passwordless/sms`).json<Response>();
-
-export const registerWithEmail = async () =>
-  api.post(`${apiPrefix}/register/passwordless/email`).json<Response>();
-
-export const sendRegisterSmsPasscode = async (phone: string) => {
-  await api
-    .post(`${apiPrefix}/passwordless/sms/send`, {
-      json: {
-        phone,
-        flow: MessageTypes.Register,
-      },
-    })
-    .json();
-
-  return { success: true };
-};
-
-export const verifyRegisterSmsPasscode = async (phone: string, code: string) =>
-  api
-    .post(`${apiPrefix}/passwordless/sms/verify`, {
-      json: {
-        phone,
-        code,
-        flow: MessageTypes.Register,
-      },
-    })
-    .json<Response>();
-
-export const sendRegisterEmailPasscode = async (email: string) => {
-  await api
-    .post(`${apiPrefix}/passwordless/email/send`, {
-      json: {
-        email,
-        flow: MessageTypes.Register,
-      },
-    })
-    .json();
-
-  return { success: true };
-};
-
-export const verifyRegisterEmailPasscode = async (email: string, code: string) =>
-  api
-    .post(`${apiPrefix}/passwordless/email/verify`, {
-      json: {
-        email,
-        code,
-        flow: MessageTypes.Register,
-      },
-    })
-    .json<Response>();
diff --git a/packages/ui/src/apis/sign-in.ts b/packages/ui/src/apis/sign-in.ts
deleted file mode 100644
index db5635d00..000000000
--- a/packages/ui/src/apis/sign-in.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { MessageTypes } from '@logto/connector-kit';
-
-import api from './api';
-import { bindSocialAccount } from './social';
-
-const apiPrefix = '/api/session';
-
-type Response = {
-  redirectTo: string;
-};
-
-export const signInWithSms = async (socialToBind?: string) => {
-  const result = await api.post(`${apiPrefix}/sign-in/passwordless/sms`).json<Response>();
-
-  if (result.redirectTo && socialToBind) {
-    await bindSocialAccount(socialToBind);
-  }
-
-  return result;
-};
-
-export const signInWithEmail = async (socialToBind?: string) => {
-  const result = await api.post(`${apiPrefix}/sign-in/passwordless/email`).json<Response>();
-
-  if (result.redirectTo && socialToBind) {
-    await bindSocialAccount(socialToBind);
-  }
-
-  return result;
-};
-
-export const sendSignInSmsPasscode = async (phone: string) => {
-  await api
-    .post(`${apiPrefix}/passwordless/sms/send`, {
-      json: {
-        phone,
-        flow: MessageTypes.SignIn,
-      },
-    })
-    .json();
-
-  return { success: true };
-};
-
-export const verifySignInSmsPasscode = async (
-  phone: string,
-  code: string,
-  socialToBind?: string
-) => {
-  const result = await api
-    .post(`${apiPrefix}/passwordless/sms/verify`, {
-      json: {
-        phone,
-        code,
-        flow: MessageTypes.SignIn,
-      },
-    })
-    .json<Response>();
-
-  if (result.redirectTo && socialToBind) {
-    await bindSocialAccount(socialToBind);
-  }
-
-  return result;
-};
-
-export const sendSignInEmailPasscode = async (email: string) => {
-  await api
-    .post(`${apiPrefix}/passwordless/email/send`, {
-      json: {
-        email,
-        flow: MessageTypes.SignIn,
-      },
-    })
-    .json();
-
-  return { success: true };
-};
-
-export const verifySignInEmailPasscode = async (
-  email: string,
-  code: string,
-  socialToBind?: string
-) => {
-  const result = await api
-    .post(`${apiPrefix}/passwordless/email/verify`, {
-      json: {
-        email,
-        code,
-        flow: MessageTypes.SignIn,
-      },
-    })
-    .json<Response>();
-
-  if (result.redirectTo && socialToBind) {
-    await bindSocialAccount(socialToBind);
-  }
-
-  return result;
-};
diff --git a/packages/ui/src/apis/utils.ts b/packages/ui/src/apis/utils.ts
index ca091245a..0ecbfe228 100644
--- a/packages/ui/src/apis/utils.ts
+++ b/packages/ui/src/apis/utils.ts
@@ -1,47 +1,22 @@
-import { SignInIdentifier } from '@logto/schemas';
+import { InteractionEvent } from '@logto/schemas';
 
 import { UserFlow } from '@/types';
 
-import { sendContinueSetEmailPasscode, sendContinueSetPhonePasscode } from './continue';
-import { sendForgotPasswordEmailPasscode, sendForgotPasswordSmsPasscode } from './forgot-password';
-import { sendRegisterEmailPasscode, sendRegisterSmsPasscode } from './register';
-import { sendSignInEmailPasscode, sendSignInSmsPasscode } from './sign-in';
+import type { SendPasscodePayload } from './interaction';
+import { putInteraction, sendPasscode } from './interaction';
 
-export type PasscodeChannel = SignInIdentifier.Email | SignInIdentifier.Sms;
-
-// TODO: @simeng-li merge in to one single api
-
-export const getSendPasscodeApi = (
-  type: UserFlow,
-  method: PasscodeChannel
-): ((_address: string) => Promise<{ success: boolean }>) => {
-  if (type === UserFlow.forgotPassword && method === SignInIdentifier.Email) {
-    return sendForgotPasswordEmailPasscode;
+export const getSendPasscodeApi = (type: UserFlow) => async (payload: SendPasscodePayload) => {
+  if (type === UserFlow.forgotPassword) {
+    await putInteraction(InteractionEvent.ForgotPassword);
   }
 
-  if (type === UserFlow.forgotPassword && method === SignInIdentifier.Sms) {
-    return sendForgotPasswordSmsPasscode;
+  if (type === UserFlow.signIn) {
+    await putInteraction(InteractionEvent.SignIn);
   }
 
-  if (type === UserFlow.signIn && method === SignInIdentifier.Email) {
-    return sendSignInEmailPasscode;
+  if (type === UserFlow.register) {
+    await putInteraction(InteractionEvent.Register);
   }
 
-  if (type === UserFlow.signIn && method === SignInIdentifier.Sms) {
-    return sendSignInSmsPasscode;
-  }
-
-  if (type === UserFlow.register && method === SignInIdentifier.Email) {
-    return sendRegisterEmailPasscode;
-  }
-
-  if (type === UserFlow.register && method === SignInIdentifier.Sms) {
-    return sendRegisterSmsPasscode;
-  }
-
-  if (type === UserFlow.continue && method === SignInIdentifier.Email) {
-    return sendContinueSetEmailPasscode;
-  }
-
-  return sendContinueSetPhonePasscode;
+  return sendPasscode(payload);
 };
diff --git a/packages/ui/src/containers/EmailForm/EmailContinue.test.tsx b/packages/ui/src/containers/EmailForm/EmailContinue.test.tsx
index b803f28e5..4c6fcefdf 100644
--- a/packages/ui/src/containers/EmailForm/EmailContinue.test.tsx
+++ b/packages/ui/src/containers/EmailForm/EmailContinue.test.tsx
@@ -2,14 +2,15 @@ import { fireEvent, waitFor, act } from '@testing-library/react';
 import { MemoryRouter } from 'react-router-dom';
 
 import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
-import { sendContinueSetEmailPasscode } from '@/apis/continue';
+import { putInteraction, sendPasscode } from '@/apis/interaction';
 
 import EmailContinue from './EmailContinue';
 
 const mockedNavigate = jest.fn();
 
-jest.mock('@/apis/continue', () => ({
-  sendContinueSetEmailPasscode: jest.fn(() => ({ success: true })),
+jest.mock('@/apis/interaction', () => ({
+  sendPasscode: jest.fn(() => ({ success: true })),
+  putInteraction: jest.fn(() => ({ success: true })),
 }));
 
 jest.mock('react-router-dom', () => ({
@@ -39,7 +40,8 @@ describe('EmailContinue', () => {
     });
 
     await waitFor(() => {
-      expect(sendContinueSetEmailPasscode).toBeCalledWith(email);
+      expect(putInteraction).not.toBeCalled();
+      expect(sendPasscode).toBeCalledWith({ email });
       expect(mockedNavigate).toBeCalledWith(
         { pathname: '/continue/email/passcode-validation', search: '' },
         { state: { email } }
diff --git a/packages/ui/src/containers/EmailForm/EmailForm.test.tsx b/packages/ui/src/containers/EmailForm/EmailForm.test.tsx
index 6f19a56b1..e926d3038 100644
--- a/packages/ui/src/containers/EmailForm/EmailForm.test.tsx
+++ b/packages/ui/src/containers/EmailForm/EmailForm.test.tsx
@@ -138,7 +138,7 @@ describe('<EmailForm/>', () => {
     });
 
     await waitFor(() => {
-      expect(onSubmit).toBeCalledWith('foo@logto.io');
+      expect(onSubmit).toBeCalledWith({ email: 'foo@logto.io' });
     });
   });
 
@@ -166,7 +166,7 @@ describe('<EmailForm/>', () => {
     });
 
     await waitFor(() => {
-      expect(onSubmit).toBeCalledWith('foo@logto.io');
+      expect(onSubmit).toBeCalledWith({ email: 'foo@logto.io' });
     });
   });
 });
diff --git a/packages/ui/src/containers/EmailForm/EmailForm.tsx b/packages/ui/src/containers/EmailForm/EmailForm.tsx
index d8d7a13ae..7414039b4 100644
--- a/packages/ui/src/containers/EmailForm/EmailForm.tsx
+++ b/packages/ui/src/containers/EmailForm/EmailForm.tsx
@@ -23,7 +23,7 @@ type Props = {
   errorMessage?: string;
   submitButtonText?: TFuncKey;
   clearErrorMessage?: () => void;
-  onSubmit: (email: string) => Promise<void> | void;
+  onSubmit: (payload: { email: string }) => Promise<void> | void;
 };
 
 type FieldState = {
@@ -59,9 +59,9 @@ const EmailForm = ({
         return;
       }
 
-      await onSubmit(fieldValue.email);
+      await onSubmit(fieldValue);
     },
-    [validateForm, hasTerms, termsValidation, onSubmit, fieldValue.email]
+    [validateForm, hasTerms, termsValidation, onSubmit, fieldValue]
   );
 
   const { onChange, ...rest } = register('email', emailValidation);
diff --git a/packages/ui/src/containers/EmailForm/EmailRegister.test.tsx b/packages/ui/src/containers/EmailForm/EmailRegister.test.tsx
index a4fa7d274..fd4356edf 100644
--- a/packages/ui/src/containers/EmailForm/EmailRegister.test.tsx
+++ b/packages/ui/src/containers/EmailForm/EmailRegister.test.tsx
@@ -1,15 +1,17 @@
+import { InteractionEvent } from '@logto/schemas';
 import { fireEvent, waitFor, act } from '@testing-library/react';
 import { MemoryRouter } from 'react-router-dom';
 
 import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
-import { sendRegisterEmailPasscode } from '@/apis/register';
+import { putInteraction, sendPasscode } from '@/apis/interaction';
 
 import EmailRegister from './EmailRegister';
 
 const mockedNavigate = jest.fn();
 
-jest.mock('@/apis/register', () => ({
-  sendRegisterEmailPasscode: jest.fn(() => ({ success: true })),
+jest.mock('@/apis/interaction', () => ({
+  sendPasscode: jest.fn(() => ({ success: true })),
+  putInteraction: jest.fn(() => ({ success: true })),
 }));
 
 jest.mock('react-router-dom', () => ({
@@ -39,7 +41,8 @@ describe('EmailRegister', () => {
     });
 
     await waitFor(() => {
-      expect(sendRegisterEmailPasscode).toBeCalledWith(email);
+      expect(putInteraction).toBeCalledWith(InteractionEvent.Register);
+      expect(sendPasscode).toBeCalledWith({ email });
       expect(mockedNavigate).toBeCalledWith(
         { pathname: '/register/email/passcode-validation', search: '' },
         { state: { email } }
diff --git a/packages/ui/src/containers/EmailForm/EmailResetPassword.test.tsx b/packages/ui/src/containers/EmailForm/EmailResetPassword.test.tsx
index 78f19827e..dbe70d328 100644
--- a/packages/ui/src/containers/EmailForm/EmailResetPassword.test.tsx
+++ b/packages/ui/src/containers/EmailForm/EmailResetPassword.test.tsx
@@ -1,17 +1,18 @@
-import { SignInIdentifier } from '@logto/schemas';
+import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
 import { fireEvent, waitFor, act } from '@testing-library/react';
 import { MemoryRouter } from 'react-router-dom';
 
 import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
-import { sendForgotPasswordEmailPasscode } from '@/apis/forgot-password';
+import { putInteraction, sendPasscode } from '@/apis/interaction';
 import { UserFlow } from '@/types';
 
 import EmailResetPassword from './EmailResetPassword';
 
 const mockedNavigate = jest.fn();
 
-jest.mock('@/apis/forgot-password', () => ({
-  sendForgotPasswordEmailPasscode: jest.fn(() => ({ success: true })),
+jest.mock('@/apis/interaction', () => ({
+  sendPasscode: jest.fn(() => ({ success: true })),
+  putInteraction: jest.fn(() => ({ success: true })),
 }));
 
 jest.mock('react-router-dom', () => ({
@@ -41,7 +42,8 @@ describe('EmailRegister', () => {
     });
 
     await waitFor(() => {
-      expect(sendForgotPasswordEmailPasscode).toBeCalledWith(email);
+      expect(putInteraction).toBeCalledWith(InteractionEvent.ForgotPassword);
+      expect(sendPasscode).toBeCalledWith({ email });
       expect(mockedNavigate).toBeCalledWith(
         {
           pathname: `/${UserFlow.forgotPassword}/${SignInIdentifier.Email}/passcode-validation`,
diff --git a/packages/ui/src/containers/EmailForm/EmailSignIn.test.tsx b/packages/ui/src/containers/EmailForm/EmailSignIn.test.tsx
index 2e191141d..e7875a95f 100644
--- a/packages/ui/src/containers/EmailForm/EmailSignIn.test.tsx
+++ b/packages/ui/src/containers/EmailForm/EmailSignIn.test.tsx
@@ -1,16 +1,17 @@
-import { SignInIdentifier } from '@logto/schemas';
+import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
 import { fireEvent, waitFor, act } from '@testing-library/react';
 import { MemoryRouter } from 'react-router-dom';
 
 import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
-import { sendSignInEmailPasscode } from '@/apis/sign-in';
+import { sendPasscode, putInteraction } from '@/apis/interaction';
 
 import EmailSignIn from './EmailSignIn';
 
 const mockedNavigate = jest.fn();
 
-jest.mock('@/apis/sign-in', () => ({
-  sendSignInEmailPasscode: jest.fn(() => ({ success: true })),
+jest.mock('@/apis/interaction', () => ({
+  sendPasscode: jest.fn(() => ({ success: true })),
+  putInteraction: jest.fn(() => ({ success: true })),
 }));
 
 jest.mock('react-router-dom', () => ({
@@ -51,7 +52,8 @@ describe('EmailSignIn', () => {
     });
 
     await waitFor(() => {
-      expect(sendSignInEmailPasscode).not.toBeCalled();
+      expect(putInteraction).not.toBeCalled();
+      expect(sendPasscode).not.toBeCalled();
       expect(mockedNavigate).toBeCalledWith(
         { pathname: '/sign-in/email/password', search: '' },
         { state: { email } }
@@ -59,7 +61,7 @@ describe('EmailSignIn', () => {
     });
   });
 
-  test('EmailSignIn form with password true but not primary verification code false', async () => {
+  test('EmailSignIn form with password true but verification code false', async () => {
     const { container, getByText } = renderWithPageContext(
       <MemoryRouter>
         <EmailSignIn
@@ -85,7 +87,8 @@ describe('EmailSignIn', () => {
     });
 
     await waitFor(() => {
-      expect(sendSignInEmailPasscode).not.toBeCalled();
+      expect(putInteraction).not.toBeCalled();
+      expect(sendPasscode).not.toBeCalled();
       expect(mockedNavigate).toBeCalledWith(
         { pathname: '/sign-in/email/password', search: '' },
         { state: { email } }
@@ -93,7 +96,7 @@ describe('EmailSignIn', () => {
     });
   });
 
-  test('EmailSignIn form with password true but not primary verification code true', async () => {
+  test('EmailSignIn form with password true but not primary, verification code true', async () => {
     const { container, getByText } = renderWithPageContext(
       <MemoryRouter>
         <EmailSignIn
@@ -120,7 +123,8 @@ describe('EmailSignIn', () => {
     });
 
     await waitFor(() => {
-      expect(sendSignInEmailPasscode).toBeCalledWith(email);
+      expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
+      expect(sendPasscode).toBeCalledWith({ email });
       expect(mockedNavigate).toBeCalledWith(
         { pathname: '/sign-in/email/passcode-validation', search: '' },
         { state: { email } }
@@ -155,7 +159,8 @@ describe('EmailSignIn', () => {
     });
 
     await waitFor(() => {
-      expect(sendSignInEmailPasscode).toBeCalledWith(email);
+      expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
+      expect(sendPasscode).toBeCalledWith({ email });
       expect(mockedNavigate).toBeCalledWith(
         { pathname: '/sign-in/email/passcode-validation', search: '' },
         { state: { email } }
diff --git a/packages/ui/src/containers/PasscodeValidation/index.test.tsx b/packages/ui/src/containers/PasscodeValidation/index.test.tsx
index 7903ecf5e..afde4105a 100644
--- a/packages/ui/src/containers/PasscodeValidation/index.test.tsx
+++ b/packages/ui/src/containers/PasscodeValidation/index.test.tsx
@@ -3,16 +3,10 @@ import { act, fireEvent, waitFor } from '@testing-library/react';
 
 import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
 import {
-  verifyContinueSetEmailPasscode,
-  continueApi,
-  verifyContinueSetSmsPasscode,
-} from '@/apis/continue';
-import {
-  verifyForgotPasswordEmailPasscode,
-  verifyForgotPasswordSmsPasscode,
-} from '@/apis/forgot-password';
-import { verifyRegisterEmailPasscode, verifyRegisterSmsPasscode } from '@/apis/register';
-import { verifySignInEmailPasscode, verifySignInSmsPasscode } from '@/apis/sign-in';
+  verifyForgotPasswordPasscodeIdentifier,
+  signInWithPasscodeIdentifier,
+  addProfileWithPasscodeIdentifier,
+} from '@/apis/interaction';
 import { UserFlow } from '@/types';
 
 import PasscodeValidation from '.';
@@ -32,25 +26,10 @@ jest.mock('@/apis/utils', () => ({
   getSendPasscodeApi: () => sendPasscodeApi,
 }));
 
-jest.mock('@/apis/sign-in', () => ({
-  verifySignInEmailPasscode: jest.fn(),
-  verifySignInSmsPasscode: jest.fn(),
-}));
-
-jest.mock('@/apis/register', () => ({
-  verifyRegisterEmailPasscode: jest.fn(),
-  verifyRegisterSmsPasscode: jest.fn(),
-}));
-
-jest.mock('@/apis/forgot-password', () => ({
-  verifyForgotPasswordEmailPasscode: jest.fn(),
-  verifyForgotPasswordSmsPasscode: jest.fn(),
-}));
-
-jest.mock('@/apis/continue', () => ({
-  verifyContinueSetEmailPasscode: jest.fn(),
-  verifyContinueSetSmsPasscode: jest.fn(),
-  continueApi: jest.fn(),
+jest.mock('@/apis/interaction', () => ({
+  verifyForgotPasswordPasscodeIdentifier: jest.fn(),
+  signInWithPasscodeIdentifier: jest.fn(),
+  addProfileWithPasscodeIdentifier: jest.fn(),
 }));
 
 describe('<PasscodeValidation />', () => {
@@ -103,12 +82,12 @@ describe('<PasscodeValidation />', () => {
       fireEvent.click(resendButton);
     });
 
-    expect(sendPasscodeApi).toBeCalledWith(email);
+    expect(sendPasscodeApi).toBeCalledWith({ email });
   });
 
   describe('sign-in', () => {
     it('fire email sign-in validate passcode event', async () => {
-      (verifySignInEmailPasscode as jest.Mock).mockImplementationOnce(() => ({
+      (signInWithPasscodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
         redirectTo: 'foo.com',
       }));
 
@@ -124,7 +103,10 @@ describe('<PasscodeValidation />', () => {
       }
 
       await waitFor(() => {
-        expect(verifySignInEmailPasscode).toBeCalledWith(email, '111111', undefined);
+        expect(signInWithPasscodeIdentifier).toBeCalledWith(
+          { email, passcode: '111111' },
+          undefined
+        );
       });
 
       await waitFor(() => {
@@ -133,7 +115,7 @@ describe('<PasscodeValidation />', () => {
     });
 
     it('fire sms sign-in validate passcode event', async () => {
-      (verifySignInSmsPasscode as jest.Mock).mockImplementationOnce(() => ({
+      (signInWithPasscodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
         redirectTo: 'foo.com',
       }));
 
@@ -149,7 +131,13 @@ describe('<PasscodeValidation />', () => {
       }
 
       await waitFor(() => {
-        expect(verifySignInSmsPasscode).toBeCalledWith(phone, '111111', undefined);
+        expect(signInWithPasscodeIdentifier).toBeCalledWith(
+          {
+            phone,
+            passcode: '111111',
+          },
+          undefined
+        );
       });
 
       await waitFor(() => {
@@ -160,7 +148,7 @@ describe('<PasscodeValidation />', () => {
 
   describe('register', () => {
     it('fire email register validate passcode event', async () => {
-      (verifyRegisterEmailPasscode as jest.Mock).mockImplementationOnce(() => ({
+      (addProfileWithPasscodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
         redirectTo: 'foo.com',
       }));
 
@@ -180,7 +168,10 @@ describe('<PasscodeValidation />', () => {
       }
 
       await waitFor(() => {
-        expect(verifyRegisterEmailPasscode).toBeCalledWith(email, '111111');
+        expect(addProfileWithPasscodeIdentifier).toBeCalledWith({
+          email,
+          passcode: '111111',
+        });
       });
 
       await waitFor(() => {
@@ -189,7 +180,7 @@ describe('<PasscodeValidation />', () => {
     });
 
     it('fire sms register validate passcode event', async () => {
-      (verifyRegisterSmsPasscode as jest.Mock).mockImplementationOnce(() => ({
+      (addProfileWithPasscodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
         redirectTo: 'foo.com',
       }));
 
@@ -205,7 +196,7 @@ describe('<PasscodeValidation />', () => {
       }
 
       await waitFor(() => {
-        expect(verifyRegisterSmsPasscode).toBeCalledWith(phone, '111111');
+        expect(addProfileWithPasscodeIdentifier).toBeCalledWith({ phone, passcode: '111111' });
       });
 
       await waitFor(() => {
@@ -216,7 +207,7 @@ describe('<PasscodeValidation />', () => {
 
   describe('forgot password', () => {
     it('fire email forgot-password validate passcode event', async () => {
-      (verifyForgotPasswordEmailPasscode as jest.Mock).mockImplementationOnce(() => ({
+      (verifyForgotPasswordPasscodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
         success: true,
       }));
 
@@ -237,17 +228,17 @@ describe('<PasscodeValidation />', () => {
       }
 
       await waitFor(() => {
-        expect(verifyForgotPasswordEmailPasscode).toBeCalledWith(email, '111111');
+        expect(verifyForgotPasswordPasscodeIdentifier).toBeCalledWith({
+          email,
+          passcode: '111111',
+        });
       });
 
-      await waitFor(() => {
-        expect(window.location.replace).not.toBeCalled();
-        expect(mockedNavigate).toBeCalledWith('/forgot-password/reset', { replace: true });
-      });
+      // TODO: @simeng test exception flow to fulfill the password
     });
 
     it('fire sms forgot-password validate passcode event', async () => {
-      (verifyForgotPasswordSmsPasscode as jest.Mock).mockImplementationOnce(() => ({
+      (verifyForgotPasswordPasscodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
         success: true,
       }));
 
@@ -268,22 +259,21 @@ describe('<PasscodeValidation />', () => {
       }
 
       await waitFor(() => {
-        expect(verifyForgotPasswordSmsPasscode).toBeCalledWith(phone, '111111');
+        expect(verifyForgotPasswordPasscodeIdentifier).toBeCalledWith({
+          phone,
+          passcode: '111111',
+        });
       });
 
-      await waitFor(() => {
-        expect(window.location.replace).not.toBeCalled();
-        expect(mockedNavigate).toBeCalledWith('/forgot-password/reset', { replace: true });
-      });
+      // TODO: @simeng test exception flow to fulfill the password
     });
   });
 
   describe('continue flow', () => {
     it('set email', async () => {
-      (verifyContinueSetEmailPasscode as jest.Mock).mockImplementationOnce(() => ({
-        success: true,
+      (addProfileWithPasscodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
+        redirectTo: '/redirect',
       }));
-      (continueApi as jest.Mock).mockImplementationOnce(() => ({ redirectTo: '/redirect' }));
 
       const { container } = renderWithPageContext(
         <PasscodeValidation
@@ -302,20 +292,24 @@ describe('<PasscodeValidation />', () => {
       }
 
       await waitFor(() => {
-        expect(verifyContinueSetEmailPasscode).toBeCalledWith(email, '111111');
+        expect(addProfileWithPasscodeIdentifier).toBeCalledWith(
+          {
+            email,
+            passcode: '111111',
+          },
+          undefined
+        );
       });
 
       await waitFor(() => {
-        expect(continueApi).toBeCalledWith('email', email, undefined);
         expect(window.location.replace).toBeCalledWith('/redirect');
       });
     });
 
     it('set Phone', async () => {
-      (verifyContinueSetSmsPasscode as jest.Mock).mockImplementationOnce(() => ({
-        success: true,
+      (addProfileWithPasscodeIdentifier as jest.Mock).mockImplementationOnce(() => ({
+        redirectTo: '/redirect',
       }));
-      (continueApi as jest.Mock).mockImplementationOnce(() => ({ redirectTo: '/redirect' }));
 
       const { container } = renderWithPageContext(
         <PasscodeValidation type={UserFlow.continue} method={SignInIdentifier.Sms} target={phone} />
@@ -330,11 +324,16 @@ describe('<PasscodeValidation />', () => {
       }
 
       await waitFor(() => {
-        expect(verifyContinueSetSmsPasscode).toBeCalledWith(phone, '111111');
+        expect(addProfileWithPasscodeIdentifier).toBeCalledWith(
+          {
+            phone,
+            passcode: '111111',
+          },
+          undefined
+        );
       });
 
       await waitFor(() => {
-        expect(continueApi).toBeCalledWith('phone', phone, undefined);
         expect(window.location.replace).toBeCalledWith('/redirect');
       });
     });
diff --git a/packages/ui/src/containers/PasscodeValidation/use-continue-set-email-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-continue-set-email-passcode-validation.ts
index 9cc5c47fa..c4a00fc2e 100644
--- a/packages/ui/src/containers/PasscodeValidation/use-continue-set-email-passcode-validation.ts
+++ b/packages/ui/src/containers/PasscodeValidation/use-continue-set-email-passcode-validation.ts
@@ -1,7 +1,7 @@
 import { SignInIdentifier } from '@logto/schemas';
 import { useMemo, useCallback } from 'react';
 
-import { verifyContinueSetEmailPasscode, continueApi } from '@/apis/continue';
+import { addProfileWithPasscodeIdentifier } from '@/apis/interaction';
 import type { ErrorHandlers } from '@/hooks/use-api';
 import useApi from '@/hooks/use-api';
 import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
@@ -24,45 +24,34 @@ const useContinueSetEmailPasscodeValidation = (email: string, errorCallback?: ()
 
   const verifyPasscodeErrorHandlers: ErrorHandlers = useMemo(
     () => ({
+      'user.email_not_exist': identifierNotExistErrorHandler,
+      ...requiredProfileErrorHandler,
       ...sharedErrorHandlers,
       callback: errorCallback,
     }),
-    [errorCallback, sharedErrorHandlers]
+    [
+      errorCallback,
+      identifierNotExistErrorHandler,
+      requiredProfileErrorHandler,
+      sharedErrorHandlers,
+    ]
   );
 
   const { run: verifyPasscode } = useApi(
-    verifyContinueSetEmailPasscode,
+    addProfileWithPasscodeIdentifier,
     verifyPasscodeErrorHandlers
   );
 
-  const setEmailErrorHandlers: ErrorHandlers = useMemo(
-    () => ({
-      'user.email_not_exist': identifierNotExistErrorHandler,
-      ...requiredProfileErrorHandler,
-      callback: errorCallback,
-    }),
-    [errorCallback, identifierNotExistErrorHandler, requiredProfileErrorHandler]
-  );
-
-  const { run: setEmail } = useApi(continueApi, setEmailErrorHandlers);
-
   const onSubmit = useCallback(
-    async (code: string) => {
-      const verified = await verifyPasscode(email, code);
-
-      if (!verified) {
-        return;
-      }
-
+    async (passcode: string) => {
       const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
-
-      const result = await setEmail('email', email, socialToBind);
+      const result = await verifyPasscode({ email, passcode }, socialToBind);
 
       if (result?.redirectTo) {
         window.location.replace(result.redirectTo);
       }
     },
-    [email, setEmail, verifyPasscode]
+    [email, verifyPasscode]
   );
 
   return {
diff --git a/packages/ui/src/containers/PasscodeValidation/use-continue-set-sms-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-continue-set-sms-passcode-validation.ts
index 748127309..c5848a7f8 100644
--- a/packages/ui/src/containers/PasscodeValidation/use-continue-set-sms-passcode-validation.ts
+++ b/packages/ui/src/containers/PasscodeValidation/use-continue-set-sms-passcode-validation.ts
@@ -1,7 +1,7 @@
 import { SignInIdentifier } from '@logto/schemas';
 import { useMemo, useCallback } from 'react';
 
-import { verifyContinueSetSmsPasscode, continueApi } from '@/apis/continue';
+import { addProfileWithPasscodeIdentifier } from '@/apis/interaction';
 import type { ErrorHandlers } from '@/hooks/use-api';
 import useApi from '@/hooks/use-api';
 import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
@@ -24,42 +24,34 @@ const useContinueSetSmsPasscodeValidation = (phone: string, errorCallback?: () =
 
   const verifyPasscodeErrorHandlers: ErrorHandlers = useMemo(
     () => ({
+      'user.phone_not_exist': identifierNotExistErrorHandler,
+      ...requiredProfileErrorHandler,
       ...sharedErrorHandlers,
       callback: errorCallback,
     }),
-    [errorCallback, sharedErrorHandlers]
+    [
+      errorCallback,
+      identifierNotExistErrorHandler,
+      requiredProfileErrorHandler,
+      sharedErrorHandlers,
+    ]
   );
 
-  const { run: verifyPasscode } = useApi(verifyContinueSetSmsPasscode, verifyPasscodeErrorHandlers);
-
-  const setPhoneErrorHandlers: ErrorHandlers = useMemo(
-    () => ({
-      'user.phone_not_exist': identifierNotExistErrorHandler,
-      ...requiredProfileErrorHandler,
-      callback: errorCallback,
-    }),
-    [errorCallback, identifierNotExistErrorHandler, requiredProfileErrorHandler]
+  const { run: verifyPasscode } = useApi(
+    addProfileWithPasscodeIdentifier,
+    verifyPasscodeErrorHandlers
   );
 
-  const { run: setPhone } = useApi(continueApi, setPhoneErrorHandlers);
-
   const onSubmit = useCallback(
-    async (code: string) => {
-      const verified = await verifyPasscode(phone, code);
-
-      if (!verified) {
-        return;
-      }
-
+    async (passcode: string) => {
       const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
-
-      const result = await setPhone('phone', phone, socialToBind);
+      const result = await verifyPasscode({ phone, passcode }, socialToBind);
 
       if (result?.redirectTo) {
         window.location.replace(result.redirectTo);
       }
     },
-    [phone, setPhone, verifyPasscode]
+    [phone, verifyPasscode]
   );
 
   return {
diff --git a/packages/ui/src/containers/PasscodeValidation/use-forgot-password-email-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-forgot-password-email-passcode-validation.ts
index 3d3ec9dd3..b51da3254 100644
--- a/packages/ui/src/containers/PasscodeValidation/use-forgot-password-email-passcode-validation.ts
+++ b/packages/ui/src/containers/PasscodeValidation/use-forgot-password-email-passcode-validation.ts
@@ -2,7 +2,7 @@ import { SignInIdentifier } from '@logto/schemas';
 import { useMemo, useEffect, useCallback } from 'react';
 import { useNavigate } from 'react-router-dom';
 
-import { verifyForgotPasswordEmailPasscode } from '@/apis/forgot-password';
+import { verifyForgotPasswordPasscodeIdentifier } from '@/apis/interaction';
 import type { ErrorHandlers } from '@/hooks/use-api';
 import useApi from '@/hooks/use-api';
 import { UserFlow } from '@/types';
@@ -23,24 +23,30 @@ const useForgotPasswordEmailPasscodeValidation = (email: string, errorCallback?:
   const errorHandlers: ErrorHandlers = useMemo(
     () => ({
       'user.email_not_exist': identifierNotExistErrorHandler,
+      'user.new_password_required_in_profile': () => {
+        navigate(`/${UserFlow.forgotPassword}/reset`, { replace: true });
+      },
       ...sharedErrorHandlers,
       callback: errorCallback,
     }),
-    [identifierNotExistErrorHandler, sharedErrorHandlers, errorCallback]
+    [identifierNotExistErrorHandler, sharedErrorHandlers, errorCallback, navigate]
   );
 
-  const { result, run: verifyPasscode } = useApi(verifyForgotPasswordEmailPasscode, errorHandlers);
+  const { result, run: verifyPasscode } = useApi(
+    verifyForgotPasswordPasscodeIdentifier,
+    errorHandlers
+  );
 
   const onSubmit = useCallback(
-    async (code: string) => {
-      return verifyPasscode(email, code);
+    async (passcode: string) => {
+      return verifyPasscode({ email, passcode });
     },
     [email, verifyPasscode]
   );
 
   useEffect(() => {
     if (result) {
-      navigate(`/${UserFlow.forgotPassword}/reset`, { replace: true });
+      navigate(`/${UserFlow.signIn}`, { replace: true });
     }
   }, [navigate, result]);
 
diff --git a/packages/ui/src/containers/PasscodeValidation/use-forgot-password-sms-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-forgot-password-sms-passcode-validation.ts
index d46c6fd8e..ad8a064ee 100644
--- a/packages/ui/src/containers/PasscodeValidation/use-forgot-password-sms-passcode-validation.ts
+++ b/packages/ui/src/containers/PasscodeValidation/use-forgot-password-sms-passcode-validation.ts
@@ -2,7 +2,7 @@ import { SignInIdentifier } from '@logto/schemas';
 import { useMemo, useEffect, useCallback } from 'react';
 import { useNavigate } from 'react-router-dom';
 
-import { verifyForgotPasswordSmsPasscode } from '@/apis/forgot-password';
+import { verifyForgotPasswordPasscodeIdentifier } from '@/apis/interaction';
 import type { ErrorHandlers } from '@/hooks/use-api';
 import useApi from '@/hooks/use-api';
 import { UserFlow } from '@/types';
@@ -13,6 +13,7 @@ import useSharedErrorHandler from './use-shared-error-handler';
 const useForgotPasswordSmsPasscodeValidation = (phone: string, errorCallback?: () => void) => {
   const navigate = useNavigate();
   const { sharedErrorHandlers, errorMessage, clearErrorMessage } = useSharedErrorHandler();
+
   const identifierNotExistErrorHandler = useIdentifierErrorAlert(
     UserFlow.forgotPassword,
     SignInIdentifier.Sms,
@@ -22,24 +23,30 @@ const useForgotPasswordSmsPasscodeValidation = (phone: string, errorCallback?: (
   const errorHandlers: ErrorHandlers = useMemo(
     () => ({
       'user.phone_not_exist': identifierNotExistErrorHandler,
+      'user.new_password_required_in_profile': () => {
+        navigate(`/${UserFlow.forgotPassword}/reset`, { replace: true });
+      },
       ...sharedErrorHandlers,
       callback: errorCallback,
     }),
-    [sharedErrorHandlers, errorCallback, identifierNotExistErrorHandler]
+    [identifierNotExistErrorHandler, sharedErrorHandlers, errorCallback, navigate]
   );
 
-  const { result, run: verifyPasscode } = useApi(verifyForgotPasswordSmsPasscode, errorHandlers);
+  const { result, run: verifyPasscode } = useApi(
+    verifyForgotPasswordPasscodeIdentifier,
+    errorHandlers
+  );
 
   const onSubmit = useCallback(
-    async (code: string) => {
-      return verifyPasscode(phone, code);
+    async (passcode: string) => {
+      return verifyPasscode({ phone, passcode });
     },
     [phone, verifyPasscode]
   );
 
   useEffect(() => {
     if (result) {
-      navigate(`/${UserFlow.forgotPassword}/reset`, { replace: true });
+      navigate(`/${UserFlow.signIn}`, { replace: true });
     }
   }, [navigate, result]);
 
diff --git a/packages/ui/src/containers/PasscodeValidation/use-register-with-email-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-register-with-email-passcode-validation.ts
index 3bb3614d7..f8fdd4d02 100644
--- a/packages/ui/src/containers/PasscodeValidation/use-register-with-email-passcode-validation.ts
+++ b/packages/ui/src/containers/PasscodeValidation/use-register-with-email-passcode-validation.ts
@@ -3,8 +3,7 @@ import { useMemo, useCallback, useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate } from 'react-router-dom';
 
-import { verifyRegisterEmailPasscode } from '@/apis/register';
-import { signInWithEmail } from '@/apis/sign-in';
+import { addProfileWithPasscodeIdentifier, signInWithVerifierIdentifier } from '@/apis/interaction';
 import type { ErrorHandlers } from '@/hooks/use-api';
 import useApi from '@/hooks/use-api';
 import { useConfirmModal } from '@/hooks/use-confirm-modal';
@@ -25,7 +24,10 @@ const useRegisterWithEmailPasscodeValidation = (email: string, errorCallback?: (
 
   const requiredProfileErrorHandlers = useRequiredProfileErrorHandler(true);
 
-  const { run: signInWithEmailAsync } = useApi(signInWithEmail, requiredProfileErrorHandlers);
+  const { run: signInWithEmailAsync } = useApi(
+    signInWithVerifierIdentifier,
+    requiredProfileErrorHandlers
+  );
 
   const identifierExistErrorHandler = useIdentifierErrorAlert(
     UserFlow.register,
@@ -75,11 +77,11 @@ const useRegisterWithEmailPasscodeValidation = (email: string, errorCallback?: (
     ]
   );
 
-  const { result, run: verifyPasscode } = useApi(verifyRegisterEmailPasscode, errorHandlers);
+  const { result, run: verifyPasscode } = useApi(addProfileWithPasscodeIdentifier, errorHandlers);
 
   const onSubmit = useCallback(
-    async (code: string) => {
-      return verifyPasscode(email, code);
+    async (passcode: string) => {
+      return verifyPasscode({ email, passcode });
     },
     [email, verifyPasscode]
   );
diff --git a/packages/ui/src/containers/PasscodeValidation/use-register-with-sms-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-register-with-sms-passcode-validation.ts
index 2a52726a3..1336704e8 100644
--- a/packages/ui/src/containers/PasscodeValidation/use-register-with-sms-passcode-validation.ts
+++ b/packages/ui/src/containers/PasscodeValidation/use-register-with-sms-passcode-validation.ts
@@ -3,8 +3,7 @@ import { useMemo, useCallback, useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate } from 'react-router-dom';
 
-import { verifyRegisterSmsPasscode } from '@/apis/register';
-import { signInWithSms } from '@/apis/sign-in';
+import { addProfileWithPasscodeIdentifier, signInWithVerifierIdentifier } from '@/apis/interaction';
 import type { ErrorHandlers } from '@/hooks/use-api';
 import useApi from '@/hooks/use-api';
 import { useConfirmModal } from '@/hooks/use-confirm-modal';
@@ -25,7 +24,10 @@ const useRegisterWithSmsPasscodeValidation = (phone: string, errorCallback?: ()
 
   const requiredProfileErrorHandlers = useRequiredProfileErrorHandler(true);
 
-  const { run: signInWithSmsAsync } = useApi(signInWithSms, requiredProfileErrorHandlers);
+  const { run: signInWithSmsAsync } = useApi(
+    signInWithVerifierIdentifier,
+    requiredProfileErrorHandlers
+  );
 
   const identifierExistErrorHandler = useIdentifierErrorAlert(
     UserFlow.register,
@@ -75,7 +77,7 @@ const useRegisterWithSmsPasscodeValidation = (phone: string, errorCallback?: ()
     ]
   );
 
-  const { result, run: verifyPasscode } = useApi(verifyRegisterSmsPasscode, errorHandlers);
+  const { result, run: verifyPasscode } = useApi(addProfileWithPasscodeIdentifier, errorHandlers);
 
   useEffect(() => {
     if (result?.redirectTo) {
@@ -84,8 +86,11 @@ const useRegisterWithSmsPasscodeValidation = (phone: string, errorCallback?: ()
   }, [result]);
 
   const onSubmit = useCallback(
-    async (code: string) => {
-      return verifyPasscode(phone, code);
+    async (passcode: string) => {
+      return verifyPasscode({
+        phone,
+        passcode,
+      });
     },
     [phone, verifyPasscode]
   );
diff --git a/packages/ui/src/containers/PasscodeValidation/use-resend-passcode.ts b/packages/ui/src/containers/PasscodeValidation/use-resend-passcode.ts
index d39330915..0837e4410 100644
--- a/packages/ui/src/containers/PasscodeValidation/use-resend-passcode.ts
+++ b/packages/ui/src/containers/PasscodeValidation/use-resend-passcode.ts
@@ -1,4 +1,4 @@
-import type { SignInIdentifier } from '@logto/schemas';
+import { SignInIdentifier } from '@logto/schemas';
 import { t } from 'i18next';
 import { useCallback, useContext } from 'react';
 import { useTimer } from 'react-timer-hook';
@@ -29,16 +29,17 @@ const useResendPasscode = (
     expiryTimestamp: getTimeout(),
   });
 
-  const { run: sendPassCode } = useApi(getSendPasscodeApi(type, method));
+  const { run: sendPassCode } = useApi(getSendPasscodeApi(type));
 
   const onResendPasscode = useCallback(async () => {
-    const result = await sendPassCode(target);
+    const payload = method === SignInIdentifier.Email ? { email: target } : { phone: target };
+    const result = await sendPassCode(payload);
 
     if (result) {
       setToast(t('description.passcode_sent'));
       restart(getTimeout(), true);
     }
-  }, [restart, sendPassCode, setToast, target]);
+  }, [method, restart, sendPassCode, setToast, target]);
 
   return {
     seconds,
diff --git a/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-email-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-email-passcode-validation.ts
index 0844b3bb9..a3229a7fb 100644
--- a/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-email-passcode-validation.ts
+++ b/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-email-passcode-validation.ts
@@ -3,8 +3,7 @@ import { useMemo, useCallback, useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate } from 'react-router-dom';
 
-import { registerWithEmail } from '@/apis/register';
-import { verifySignInEmailPasscode } from '@/apis/sign-in';
+import { signInWithPasscodeIdentifier, registerWithVerifiedIdentifier } from '@/apis/interaction';
 import type { ErrorHandlers } from '@/hooks/use-api';
 import useApi from '@/hooks/use-api';
 import { useConfirmModal } from '@/hooks/use-confirm-modal';
@@ -26,7 +25,10 @@ const useSignInWithEmailPasscodeValidation = (email: string, errorCallback?: ()
 
   const requiredProfileErrorHandlers = useRequiredProfileErrorHandler(true);
 
-  const { run: registerWithEmailAsync } = useApi(registerWithEmail, requiredProfileErrorHandlers);
+  const { run: registerWithEmailAsync } = useApi(
+    registerWithVerifiedIdentifier,
+    requiredProfileErrorHandlers
+  );
 
   const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
 
@@ -51,7 +53,7 @@ const useSignInWithEmailPasscodeValidation = (email: string, errorCallback?: ()
       return;
     }
 
-    const result = await registerWithEmailAsync();
+    const result = await registerWithEmailAsync({ email });
 
     if (result?.redirectTo) {
       window.location.replace(result.redirectTo);
@@ -80,7 +82,10 @@ const useSignInWithEmailPasscodeValidation = (email: string, errorCallback?: ()
     ]
   );
 
-  const { result, run: verifyPasscode } = useApi(verifySignInEmailPasscode, errorHandlers);
+  const { result, run: asyncSignInWithPasscodeIdentifier } = useApi(
+    signInWithPasscodeIdentifier,
+    errorHandlers
+  );
 
   useEffect(() => {
     if (result?.redirectTo) {
@@ -89,10 +94,16 @@ const useSignInWithEmailPasscodeValidation = (email: string, errorCallback?: ()
   }, [result]);
 
   const onSubmit = useCallback(
-    async (code: string) => {
-      return verifyPasscode(email, code, socialToBind);
+    async (passcode: string) => {
+      return asyncSignInWithPasscodeIdentifier(
+        {
+          email,
+          passcode,
+        },
+        socialToBind
+      );
     },
-    [email, socialToBind, verifyPasscode]
+    [asyncSignInWithPasscodeIdentifier, email, socialToBind]
   );
 
   return {
diff --git a/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-sms-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-sms-passcode-validation.ts
index a63f2c3f2..8e8c3abbb 100644
--- a/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-sms-passcode-validation.ts
+++ b/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-sms-passcode-validation.ts
@@ -3,8 +3,7 @@ import { useMemo, useCallback, useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate } from 'react-router-dom';
 
-import { registerWithSms } from '@/apis/register';
-import { verifySignInSmsPasscode } from '@/apis/sign-in';
+import { signInWithPasscodeIdentifier, registerWithVerifiedIdentifier } from '@/apis/interaction';
 import type { ErrorHandlers } from '@/hooks/use-api';
 import useApi from '@/hooks/use-api';
 import { useConfirmModal } from '@/hooks/use-confirm-modal';
@@ -26,7 +25,10 @@ const useSignInWithSmsPasscodeValidation = (phone: string, errorCallback?: () =>
 
   const requiredProfileErrorHandlers = useRequiredProfileErrorHandler(true);
 
-  const { run: registerWithSmsAsync } = useApi(registerWithSms, requiredProfileErrorHandlers);
+  const { run: registerWithSmsAsync } = useApi(
+    registerWithVerifiedIdentifier,
+    requiredProfileErrorHandlers
+  );
 
   const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
 
@@ -51,7 +53,7 @@ const useSignInWithSmsPasscodeValidation = (phone: string, errorCallback?: () =>
       return;
     }
 
-    const result = await registerWithSmsAsync();
+    const result = await registerWithSmsAsync({ phone });
 
     if (result?.redirectTo) {
       window.location.replace(result.redirectTo);
@@ -80,7 +82,10 @@ const useSignInWithSmsPasscodeValidation = (phone: string, errorCallback?: () =>
     ]
   );
 
-  const { result, run: verifyPasscode } = useApi(verifySignInSmsPasscode, errorHandlers);
+  const { result, run: asyncSignInWithPasscodeIdentifier } = useApi(
+    signInWithPasscodeIdentifier,
+    errorHandlers
+  );
 
   useEffect(() => {
     if (result?.redirectTo) {
@@ -90,9 +95,15 @@ const useSignInWithSmsPasscodeValidation = (phone: string, errorCallback?: () =>
 
   const onSubmit = useCallback(
     async (code: string) => {
-      return verifyPasscode(phone, code, socialToBind);
+      return asyncSignInWithPasscodeIdentifier(
+        {
+          phone,
+          passcode: code,
+        },
+        socialToBind
+      );
     },
-    [phone, socialToBind, verifyPasscode]
+    [phone, socialToBind, asyncSignInWithPasscodeIdentifier]
   );
 
   return {
diff --git a/packages/ui/src/containers/PasswordSignInForm/PasswordlessSignInLink.tsx b/packages/ui/src/containers/PasswordSignInForm/PasswordlessSignInLink.tsx
index 76b5adf02..fe1995e05 100644
--- a/packages/ui/src/containers/PasswordSignInForm/PasswordlessSignInLink.tsx
+++ b/packages/ui/src/containers/PasswordSignInForm/PasswordlessSignInLink.tsx
@@ -1,4 +1,4 @@
-import type { SignInIdentifier } from '@logto/schemas';
+import { SignInIdentifier } from '@logto/schemas';
 import { useContext, useEffect } from 'react';
 
 import TextLink from '@/components/TextLink';
@@ -33,7 +33,7 @@ const PasswordlessSignInLink = ({ className, method, value }: Props) => {
       text="action.sign_in_via_passcode"
       onClick={() => {
         clearErrorMessage();
-        void onSubmit(value);
+        void onSubmit(method === SignInIdentifier.Email ? { email: value } : { phone: value });
       }}
     />
   );
diff --git a/packages/ui/src/containers/PasswordSignInForm/index.test.tsx b/packages/ui/src/containers/PasswordSignInForm/index.test.tsx
index 7fe22dae7..8e9f0916e 100644
--- a/packages/ui/src/containers/PasswordSignInForm/index.test.tsx
+++ b/packages/ui/src/containers/PasswordSignInForm/index.test.tsx
@@ -1,20 +1,16 @@
-import { SignInIdentifier } from '@logto/schemas';
+import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
 import { fireEvent, waitFor, act } from '@testing-library/react';
 
 import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
-import { signInWithPasswordIdentifier } from '@/apis/interaction';
-import { sendSignInEmailPasscode, sendSignInSmsPasscode } from '@/apis/sign-in';
+import { signInWithPasswordIdentifier, putInteraction, sendPasscode } from '@/apis/interaction';
 import { UserFlow } from '@/types';
 
 import PasswordSignInForm from '.';
 
-jest.mock('@/apis/sign-in', () => ({
-  sendSignInEmailPasscode: jest.fn(() => ({ success: true })),
-  sendSignInSmsPasscode: jest.fn(() => ({ success: true })),
-}));
-
 jest.mock('@/apis/interaction', () => ({
   signInWithPasswordIdentifier: jest.fn(() => ({ redirectTo: '/' })),
+  sendPasscode: jest.fn(() => ({ success: true })),
+  putInteraction: jest.fn(() => ({ success: true })),
 }));
 
 const mockedNavigate = jest.fn();
@@ -80,7 +76,8 @@ describe('PasswordSignInForm', () => {
     });
 
     await waitFor(() => {
-      expect(sendSignInEmailPasscode).toBeCalledWith(email);
+      expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
+      expect(sendPasscode).toBeCalledWith({ email });
     });
 
     expect(mockedNavigate).toBeCalledWith(
@@ -125,7 +122,8 @@ describe('PasswordSignInForm', () => {
     });
 
     await waitFor(() => {
-      expect(sendSignInSmsPasscode).toBeCalledWith(phone);
+      expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
+      expect(sendPasscode).toBeCalledWith({ phone });
     });
 
     expect(mockedNavigate).toBeCalledWith(
diff --git a/packages/ui/src/containers/PhoneForm/PhoneForm.test.tsx b/packages/ui/src/containers/PhoneForm/PhoneForm.test.tsx
index c73faed9c..5388ce55f 100644
--- a/packages/ui/src/containers/PhoneForm/PhoneForm.test.tsx
+++ b/packages/ui/src/containers/PhoneForm/PhoneForm.test.tsx
@@ -145,7 +145,7 @@ describe('<PhonePasswordless/>', () => {
     });
 
     await waitFor(() => {
-      expect(onSubmit).toBeCalledWith(`${defaultCountryCallingCode}${phoneNumber}`);
+      expect(onSubmit).toBeCalledWith({ phone: `${defaultCountryCallingCode}${phoneNumber}` });
     });
   });
 
@@ -173,7 +173,7 @@ describe('<PhonePasswordless/>', () => {
     });
 
     await waitFor(() => {
-      expect(onSubmit).toBeCalledWith(`${defaultCountryCallingCode}${phoneNumber}`);
+      expect(onSubmit).toBeCalledWith({ phone: `${defaultCountryCallingCode}${phoneNumber}` });
     });
   });
 });
diff --git a/packages/ui/src/containers/PhoneForm/PhoneForm.tsx b/packages/ui/src/containers/PhoneForm/PhoneForm.tsx
index c07d0e9bc..ab03ba34f 100644
--- a/packages/ui/src/containers/PhoneForm/PhoneForm.tsx
+++ b/packages/ui/src/containers/PhoneForm/PhoneForm.tsx
@@ -23,7 +23,7 @@ type Props = {
   errorMessage?: string;
   submitButtonText?: TFuncKey;
   clearErrorMessage?: () => void;
-  onSubmit: (phone: string) => Promise<void> | void;
+  onSubmit: (payload: { phone: string }) => Promise<void> | void;
 };
 
 type FieldState = {
@@ -79,9 +79,9 @@ const PhoneForm = ({
         return;
       }
 
-      await onSubmit(fieldValue.phone);
+      await onSubmit(fieldValue);
     },
-    [validateForm, hasTerms, termsValidation, onSubmit, fieldValue.phone]
+    [validateForm, hasTerms, termsValidation, onSubmit, fieldValue]
   );
 
   return (
diff --git a/packages/ui/src/containers/PhoneForm/SmsContinue.test.tsx b/packages/ui/src/containers/PhoneForm/SmsContinue.test.tsx
index 01dafa97e..3f52b3cad 100644
--- a/packages/ui/src/containers/PhoneForm/SmsContinue.test.tsx
+++ b/packages/ui/src/containers/PhoneForm/SmsContinue.test.tsx
@@ -2,7 +2,7 @@ import { fireEvent, waitFor, act } from '@testing-library/react';
 import { MemoryRouter } from 'react-router-dom';
 
 import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
-import { sendContinueSetPhonePasscode } from '@/apis/continue';
+import { putInteraction, sendPasscode } from '@/apis/interaction';
 import { getDefaultCountryCallingCode } from '@/utils/country-code';
 
 import SmsContinue from './SmsContinue';
@@ -14,8 +14,9 @@ jest.mock('i18next', () => ({
   language: 'en',
 }));
 
-jest.mock('@/apis/continue', () => ({
-  sendContinueSetPhonePasscode: jest.fn(() => ({ success: true })),
+jest.mock('@/apis/interaction', () => ({
+  sendPasscode: jest.fn(() => ({ success: true })),
+  putInteraction: jest.fn(() => ({ success: true })),
 }));
 
 jest.mock('react-router-dom', () => ({
@@ -47,7 +48,8 @@ describe('SmsContinue', () => {
     });
 
     await waitFor(() => {
-      expect(sendContinueSetPhonePasscode).toBeCalledWith(fullPhoneNumber);
+      expect(putInteraction).not.toBeCalled();
+      expect(sendPasscode).toBeCalledWith({ phone: fullPhoneNumber });
       expect(mockedNavigate).toBeCalledWith(
         { pathname: '/continue/sms/passcode-validation', search: '' },
         { state: { phone: fullPhoneNumber } }
diff --git a/packages/ui/src/containers/PhoneForm/SmsRegister.test.tsx b/packages/ui/src/containers/PhoneForm/SmsRegister.test.tsx
index 375262312..30adc07e3 100644
--- a/packages/ui/src/containers/PhoneForm/SmsRegister.test.tsx
+++ b/packages/ui/src/containers/PhoneForm/SmsRegister.test.tsx
@@ -1,8 +1,9 @@
+import { InteractionEvent } from '@logto/schemas';
 import { fireEvent, waitFor, act } from '@testing-library/react';
 import { MemoryRouter } from 'react-router-dom';
 
 import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
-import { sendRegisterSmsPasscode } from '@/apis/register';
+import { putInteraction, sendPasscode } from '@/apis/interaction';
 import { getDefaultCountryCallingCode } from '@/utils/country-code';
 
 import SmsRegister from './SmsRegister';
@@ -14,8 +15,9 @@ jest.mock('i18next', () => ({
   language: 'en',
 }));
 
-jest.mock('@/apis/register', () => ({
-  sendRegisterSmsPasscode: jest.fn(() => ({ success: true })),
+jest.mock('@/apis/interaction', () => ({
+  sendPasscode: jest.fn(() => ({ success: true })),
+  putInteraction: jest.fn(() => ({ success: true })),
 }));
 
 jest.mock('react-router-dom', () => ({
@@ -47,7 +49,8 @@ describe('SmsRegister', () => {
     });
 
     await waitFor(() => {
-      expect(sendRegisterSmsPasscode).toBeCalledWith(fullPhoneNumber);
+      expect(putInteraction).toBeCalledWith(InteractionEvent.Register);
+      expect(sendPasscode).toBeCalledWith({ phone: fullPhoneNumber });
       expect(mockedNavigate).toBeCalledWith(
         { pathname: '/register/sms/passcode-validation', search: '' },
         { state: { phone: fullPhoneNumber } }
diff --git a/packages/ui/src/containers/PhoneForm/SmsResetPassword.test.tsx b/packages/ui/src/containers/PhoneForm/SmsResetPassword.test.tsx
index efefd7cbc..773176c9b 100644
--- a/packages/ui/src/containers/PhoneForm/SmsResetPassword.test.tsx
+++ b/packages/ui/src/containers/PhoneForm/SmsResetPassword.test.tsx
@@ -1,9 +1,9 @@
-import { SignInIdentifier } from '@logto/schemas';
+import { SignInIdentifier, InteractionEvent } from '@logto/schemas';
 import { fireEvent, waitFor, act } from '@testing-library/react';
 import { MemoryRouter } from 'react-router-dom';
 
 import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
-import { sendForgotPasswordSmsPasscode } from '@/apis/forgot-password';
+import { putInteraction, sendPasscode } from '@/apis/interaction';
 import { UserFlow } from '@/types';
 import { getDefaultCountryCallingCode } from '@/utils/country-code';
 
@@ -16,8 +16,9 @@ jest.mock('i18next', () => ({
   language: 'en',
 }));
 
-jest.mock('@/apis/forgot-password', () => ({
-  sendForgotPasswordSmsPasscode: jest.fn(() => ({ success: true })),
+jest.mock('@/apis/interaction', () => ({
+  sendPasscode: jest.fn(() => ({ success: true })),
+  putInteraction: jest.fn(() => ({ success: true })),
 }));
 
 jest.mock('react-router-dom', () => ({
@@ -49,7 +50,8 @@ describe('SmsRegister', () => {
     });
 
     await waitFor(() => {
-      expect(sendForgotPasswordSmsPasscode).toBeCalledWith(fullPhoneNumber);
+      expect(putInteraction).toBeCalledWith(InteractionEvent.ForgotPassword);
+      expect(sendPasscode).toBeCalledWith({ phone: fullPhoneNumber });
       expect(mockedNavigate).toBeCalledWith(
         {
           pathname: `/${UserFlow.forgotPassword}/${SignInIdentifier.Sms}/passcode-validation`,
diff --git a/packages/ui/src/containers/PhoneForm/SmsSignIn.test.tsx b/packages/ui/src/containers/PhoneForm/SmsSignIn.test.tsx
index 5b6d8baf0..2e984db09 100644
--- a/packages/ui/src/containers/PhoneForm/SmsSignIn.test.tsx
+++ b/packages/ui/src/containers/PhoneForm/SmsSignIn.test.tsx
@@ -1,9 +1,9 @@
-import { SignInIdentifier } from '@logto/schemas';
+import { SignInIdentifier, InteractionEvent } from '@logto/schemas';
 import { fireEvent, waitFor, act } from '@testing-library/react';
 import { MemoryRouter } from 'react-router-dom';
 
 import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
-import { sendSignInSmsPasscode } from '@/apis/sign-in';
+import { sendPasscode, putInteraction } from '@/apis/interaction';
 import { getDefaultCountryCallingCode } from '@/utils/country-code';
 
 import SmsSignIn from './SmsSignIn';
@@ -15,8 +15,9 @@ jest.mock('i18next', () => ({
   language: 'en',
 }));
 
-jest.mock('@/apis/sign-in', () => ({
-  sendSignInSmsPasscode: jest.fn(() => ({ success: true })),
+jest.mock('@/apis/interaction', () => ({
+  sendPasscode: jest.fn(() => ({ success: true })),
+  putInteraction: jest.fn(() => ({ success: true })),
 }));
 
 jest.mock('react-router-dom', () => ({
@@ -59,7 +60,8 @@ describe('SmsSignIn', () => {
     });
 
     await waitFor(() => {
-      expect(sendSignInSmsPasscode).not.toBeCalled();
+      expect(putInteraction).not.toBeCalled();
+      expect(sendPasscode).not.toBeCalled();
       expect(mockedNavigate).toBeCalledWith(
         { pathname: '/sign-in/sms/password', search: '' },
         { state: { phone: fullPhoneNumber } }
@@ -93,7 +95,8 @@ describe('SmsSignIn', () => {
     });
 
     await waitFor(() => {
-      expect(sendSignInSmsPasscode).not.toBeCalled();
+      expect(putInteraction).not.toBeCalled();
+      expect(sendPasscode).not.toBeCalled();
       expect(mockedNavigate).toBeCalledWith(
         { pathname: '/sign-in/sms/password', search: '' },
         { state: { phone: fullPhoneNumber } }
@@ -128,7 +131,8 @@ describe('SmsSignIn', () => {
     });
 
     await waitFor(() => {
-      expect(sendSignInSmsPasscode).toBeCalledWith(fullPhoneNumber);
+      expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
+      expect(sendPasscode).toBeCalledWith({ phone: fullPhoneNumber });
       expect(mockedNavigate).toBeCalledWith(
         { pathname: '/sign-in/sms/passcode-validation', search: '' },
         { state: { phone: fullPhoneNumber } }
@@ -163,7 +167,8 @@ describe('SmsSignIn', () => {
     });
 
     await waitFor(() => {
-      expect(sendSignInSmsPasscode).toBeCalledWith(fullPhoneNumber);
+      expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
+      expect(sendPasscode).toBeCalledWith({ phone: fullPhoneNumber });
       expect(mockedNavigate).toBeCalledWith(
         { pathname: '/sign-in/sms/passcode-validation', search: '' },
         { state: { phone: fullPhoneNumber } }
diff --git a/packages/ui/src/hooks/use-continue-sign-in-with-password.ts b/packages/ui/src/hooks/use-continue-sign-in-with-password.ts
index 87c869155..fa84f19c6 100644
--- a/packages/ui/src/hooks/use-continue-sign-in-with-password.ts
+++ b/packages/ui/src/hooks/use-continue-sign-in-with-password.ts
@@ -1,18 +1,22 @@
-import { SignInIdentifier } from '@logto/schemas';
+import type { SignInIdentifier } from '@logto/schemas';
 import { useNavigate } from 'react-router-dom';
 
 import { UserFlow } from '@/types';
 
-const useContinueSignInWithPassword = (method: SignInIdentifier.Email | SignInIdentifier.Sms) => {
+const useContinueSignInWithPassword = <T extends SignInIdentifier.Email | SignInIdentifier.Sms>(
+  method: T
+) => {
   const navigate = useNavigate();
 
-  return (value: string) => {
+  type Payload = T extends SignInIdentifier.Email ? { email: string } : { phone: string };
+
+  return (payload: Payload) => {
     navigate(
       {
         pathname: `/${UserFlow.signIn}/${method}/password`,
         search: location.search,
       },
-      { state: method === SignInIdentifier.Email ? { email: value } : { phone: value } }
+      { state: payload }
     );
   };
 };
diff --git a/packages/ui/src/hooks/use-passwordless-send-code.ts b/packages/ui/src/hooks/use-passwordless-send-code.ts
index 085fdd5fa..64840c90e 100644
--- a/packages/ui/src/hooks/use-passwordless-send-code.ts
+++ b/packages/ui/src/hooks/use-passwordless-send-code.ts
@@ -7,9 +7,9 @@ import type { ErrorHandlers } from '@/hooks/use-api';
 import useApi from '@/hooks/use-api';
 import type { UserFlow } from '@/types';
 
-const usePasswordlessSendCode = (
+const usePasswordlessSendCode = <T extends SignInIdentifier.Email | SignInIdentifier.Sms>(
   flow: UserFlow,
-  method: SignInIdentifier.Email | SignInIdentifier.Sms,
+  method: T,
   replaceCurrentPage?: boolean
 ) => {
   const [errorMessage, setErrorMessage] = useState<string>();
@@ -28,13 +28,15 @@ const usePasswordlessSendCode = (
     setErrorMessage('');
   }, []);
 
-  const api = getSendPasscodeApi(flow, method);
+  const api = getSendPasscodeApi(flow);
 
   const { run: asyncSendPasscode } = useApi(api, errorHandlers);
 
+  type Payload = T extends SignInIdentifier.Email ? { email: string } : { phone: string };
+
   const onSubmit = useCallback(
-    async (value: string) => {
-      const result = await asyncSendPasscode(value);
+    async (payload: Payload) => {
+      const result = await asyncSendPasscode(payload);
 
       if (!result) {
         return;
@@ -46,7 +48,7 @@ const usePasswordlessSendCode = (
           search: location.search,
         },
         {
-          state: method === SignInIdentifier.Email ? { email: value } : { phone: value },
+          state: payload,
           replace: replaceCurrentPage,
         }
       );
diff --git a/packages/ui/src/pages/PasswordRegisterWithUsername/use-username-password-register.ts b/packages/ui/src/pages/PasswordRegisterWithUsername/use-username-password-register.ts
index 51b7d5fce..edd59e9ca 100644
--- a/packages/ui/src/pages/PasswordRegisterWithUsername/use-username-password-register.ts
+++ b/packages/ui/src/pages/PasswordRegisterWithUsername/use-username-password-register.ts
@@ -27,7 +27,7 @@ const useUsernamePasswordRegister = () => {
   const { result, run: asyncSetPassword } = useApi(setUserPassword, resetPasswordErrorHandlers);
 
   useEffect(() => {
-    if (result?.redirectTo) {
+    if (result && 'redirectTo' in result) {
       window.location.replace(result.redirectTo);
     }
   }, [result, setToast, t]);
diff --git a/packages/ui/src/pages/ResetPassword/index.test.tsx b/packages/ui/src/pages/ResetPassword/index.test.tsx
index ef4d81275..7ec23a704 100644
--- a/packages/ui/src/pages/ResetPassword/index.test.tsx
+++ b/packages/ui/src/pages/ResetPassword/index.test.tsx
@@ -2,7 +2,7 @@ import { act, waitFor, fireEvent } from '@testing-library/react';
 import { MemoryRouter, Routes, Route } from 'react-router-dom';
 
 import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
-import { resetPassword } from '@/apis/forgot-password';
+import { setUserPassword } from '@/apis/interaction';
 
 import ResetPassword from '.';
 
@@ -13,8 +13,8 @@ jest.mock('react-router-dom', () => ({
   useNavigate: () => mockedNavigate,
 }));
 
-jest.mock('@/apis/forgot-password', () => ({
-  resetPassword: jest.fn(async () => ({ redirectTo: '/' })),
+jest.mock('@/apis/interaction', () => ({
+  setUserPassword: jest.fn(async () => ({ redirectTo: '/' })),
 }));
 
 describe('ForgotPassword', () => {
@@ -51,7 +51,7 @@ describe('ForgotPassword', () => {
     });
 
     await waitFor(() => {
-      expect(resetPassword).toBeCalledWith('123456');
+      expect(setUserPassword).toBeCalledWith('123456');
     });
   });
 });
diff --git a/packages/ui/src/pages/ResetPassword/use-reset-password.ts b/packages/ui/src/pages/ResetPassword/use-reset-password.ts
index f09246805..cf33e267d 100644
--- a/packages/ui/src/pages/ResetPassword/use-reset-password.ts
+++ b/packages/ui/src/pages/ResetPassword/use-reset-password.ts
@@ -2,7 +2,7 @@ import { useMemo, useState, useContext, useEffect, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate } from 'react-router-dom';
 
-import { resetPassword } from '@/apis/forgot-password';
+import { setUserPassword } from '@/apis/interaction';
 import type { ErrorHandlers } from '@/hooks/use-api';
 import useApi from '@/hooks/use-api';
 import { useConfirmModal } from '@/hooks/use-confirm-modal';
@@ -24,11 +24,7 @@ const useResetPassword = () => {
     () => ({
       'session.verification_session_not_found': async (error) => {
         await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
-        navigate(-1);
-      },
-      'session.verification_expired': async (error) => {
-        await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
-        navigate(-1);
+        navigate(-2);
       },
       'user.same_password': (error) => {
         setErrorMessage(error.message);
@@ -37,7 +33,7 @@ const useResetPassword = () => {
     [navigate, setErrorMessage, show]
   );
 
-  const { result, run: asyncResetPassword } = useApi(resetPassword, resetPasswordErrorHandlers);
+  const { result, run: asyncResetPassword } = useApi(setUserPassword, resetPasswordErrorHandlers);
 
   useEffect(() => {
     if (result) {
diff --git a/packages/ui/src/pages/SecondaryRegister/index.test.tsx b/packages/ui/src/pages/SecondaryRegister/index.test.tsx
index cffc044ac..0ffe85502 100644
--- a/packages/ui/src/pages/SecondaryRegister/index.test.tsx
+++ b/packages/ui/src/pages/SecondaryRegister/index.test.tsx
@@ -6,7 +6,6 @@ import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider
 import { mockSignInExperienceSettings } from '@/__mocks__/logto';
 import SecondaryRegister from '@/pages/SecondaryRegister';
 
-jest.mock('@/apis/register', () => ({ register: jest.fn(async () => 0) }));
 jest.mock('i18next', () => ({
   language: 'en',
 }));
diff --git a/packages/ui/src/pages/SecondarySignIn/index.test.tsx b/packages/ui/src/pages/SecondarySignIn/index.test.tsx
index 51be21535..44f0908de 100644
--- a/packages/ui/src/pages/SecondarySignIn/index.test.tsx
+++ b/packages/ui/src/pages/SecondarySignIn/index.test.tsx
@@ -6,7 +6,6 @@ import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider
 import { mockSignInExperienceSettings } from '@/__mocks__/logto';
 import SecondarySignIn from '@/pages/SecondarySignIn';
 
-jest.mock('@/apis/register', () => ({ register: jest.fn(async () => 0) }));
 jest.mock('i18next', () => ({
   language: 'en',
 }));