diff --git a/packages/console/src/App.tsx b/packages/console/src/App.tsx
index 30830308c53..aac799a98f8 100644
--- a/packages/console/src/App.tsx
+++ b/packages/console/src/App.tsx
@@ -116,23 +116,23 @@ function Providers() {
-
-
- {/**
- * If it's not Cloud (OSS), render the tenant app container directly since only default tenant is available;
- * if it's Cloud, render the tenant app container only when a tenant ID is available (in a tenant context).
- */}
- {!isCloud || currentTenantId ? (
-
-
+
+
+
+ {/**
+ * If it's not Cloud (OSS), render the tenant app container directly since only default tenant is available;
+ * if it's Cloud, render the tenant app container only when a tenant ID is available (in a tenant context).
+ */}
+ {!isCloud || currentTenantId ? (
+
-
-
- ) : (
-
- )}
-
-
+
+ ) : (
+
+ )}
+
+
+
);
diff --git a/packages/console/src/pages/Profile/containers/VerificationCodeModal/index.tsx b/packages/console/src/pages/Profile/containers/VerificationCodeModal/index.tsx
index a5e217af1cf..83000f23994 100644
--- a/packages/console/src/pages/Profile/containers/VerificationCodeModal/index.tsx
+++ b/packages/console/src/pages/Profile/containers/VerificationCodeModal/index.tsx
@@ -61,7 +61,7 @@ function VerificationCodeModal() {
}
try {
- await api.post(`me/verification-codes/verify`, { json: { verificationCode, email, action } });
+ await api.post(`me/verification-codes/verify`, { json: { verificationCode, email } });
if (action === 'changeEmail') {
await api.patch('me', { json: { primaryEmail: email } });
diff --git a/packages/core/src/libraries/verification-status.ts b/packages/core/src/libraries/verification-status.ts
index bdf1565b713..7a40d1b5fdd 100644
--- a/packages/core/src/libraries/verification-status.ts
+++ b/packages/core/src/libraries/verification-status.ts
@@ -1,4 +1,5 @@
import { generateStandardId } from '@logto/shared';
+import { type Nullable } from '@silverhand/essentials';
import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js';
@@ -13,17 +14,21 @@ export const createVerificationStatusLibrary = (queries: Queries) => {
deleteVerificationStatusesByUserId,
} = queries.verificationStatuses;
- const createVerificationStatus = async (userId: string) => {
+ const createVerificationStatus = async (userId: string, identifier: Nullable) => {
// Remove existing verification statuses for current user.
await deleteVerificationStatusesByUserId(userId);
return insertVerificationStatus({
id: generateStandardId(),
userId,
+ verifiedIdentifier: identifier,
});
};
- const checkVerificationStatus = async (userId: string): Promise => {
+ const checkVerificationStatus = async (
+ userId: string,
+ identifier: Nullable
+ ): Promise => {
const verificationStatus = await findVerificationStatusByUserId(userId);
assertThat(verificationStatus, 'session.verification_session_not_found');
@@ -31,7 +36,15 @@ export const createVerificationStatusLibrary = (queries: Queries) => {
// The user verification status is considered valid if the user is verified within 10 minutes.
const isValid = Date.now() - verificationStatus.createdAt < verificationTimeout;
assertThat(isValid, new RequestError({ code: 'session.verification_failed', status: 422 }));
+
+ assertThat(
+ verificationStatus.verifiedIdentifier === identifier,
+ new RequestError({ code: 'session.verification_failed', status: 422 })
+ );
};
- return { createVerificationStatus, checkVerificationStatus };
+ return {
+ createVerificationStatus,
+ checkVerificationStatus,
+ };
};
diff --git a/packages/core/src/queries/verification-status.ts b/packages/core/src/queries/verification-status.ts
index 2439a9a12e0..8054b2232e6 100644
--- a/packages/core/src/queries/verification-status.ts
+++ b/packages/core/src/queries/verification-status.ts
@@ -1,5 +1,4 @@
-import type { VerificationStatus } from '@logto/schemas';
-import { VerificationStatuses } from '@logto/schemas';
+import { type VerificationStatus, VerificationStatuses } from '@logto/schemas';
import type { CommonQueryMethods } from '@silverhand/slonik';
import { sql } from '@silverhand/slonik';
diff --git a/packages/core/src/routes-me/user.ts b/packages/core/src/routes-me/user.ts
index 42fab5a3e04..30a3cab4215 100644
--- a/packages/core/src/routes-me/user.ts
+++ b/packages/core/src/routes-me/user.ts
@@ -44,8 +44,8 @@ export default function userRoutes(
'/',
koaGuard({
body: object({
- username: string().regex(usernameRegEx),
- primaryEmail: string().regex(emailRegEx),
+ username: string().regex(usernameRegEx), // OSS only
+ primaryEmail: string().regex(emailRegEx), // Cloud only
name: string().or(literal('')).nullable(),
avatar: string().url().or(literal('')).nullable(),
}).partial(),
@@ -57,6 +57,12 @@ export default function userRoutes(
const user = await findUserById(userId);
assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
+ const { primaryEmail } = body;
+ if (primaryEmail) {
+ // Check if user has verified email within 10 minutes.
+ await checkVerificationStatus(userId, primaryEmail);
+ }
+
await checkIdentifierCollision(body, userId);
const updatedUser = await updateUserById(userId, body);
@@ -113,7 +119,7 @@ export default function userRoutes(
await verifyUserPassword(user, password);
- await createVerificationStatus(userId);
+ await createVerificationStatus(userId, null);
ctx.status = 204;
@@ -128,13 +134,11 @@ export default function userRoutes(
const { id: userId } = ctx.auth;
const { password } = ctx.guard.body;
- const { isSuspended, passwordEncrypted: oldPasswordEncrypted } = await findUserById(userId);
+ const { isSuspended } = await findUserById(userId);
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
- if (oldPasswordEncrypted) {
- await checkVerificationStatus(userId);
- }
+ await checkVerificationStatus(userId, null);
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod });
diff --git a/packages/core/src/routes-me/verification-code.ts b/packages/core/src/routes-me/verification-code.ts
index 0b3d136ddcb..1a24d62d16b 100644
--- a/packages/core/src/routes-me/verification-code.ts
+++ b/packages/core/src/routes-me/verification-code.ts
@@ -1,11 +1,12 @@
import { TemplateType } from '@logto/connector-kit';
import { emailRegEx } from '@logto/core-kit';
-import { literal, object, string, union } from 'zod';
+import { object, string } from 'zod';
-import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type { RouterInitArgs } from '#src/routes/types.js';
-import assertThat from '#src/utils/assert-that.js';
+
+import RequestError from '../errors/RequestError/index.js';
+import assertThat from '../utils/assert-that.js';
import type { AuthedMeRouter } from './types.js';
@@ -44,21 +45,18 @@ export default function verificationCodeRoutes(
body: object({
email: string().regex(emailRegEx),
verificationCode: string().min(1),
- action: union([literal('changeEmail'), literal('changePassword')]),
}),
}),
async (ctx, next) => {
const { id: userId } = ctx.auth;
- const { verificationCode, action, ...identifier } = ctx.guard.body;
- await verifyPasscode(undefined, codeType, verificationCode, identifier);
+ const { verificationCode, ...identifier } = ctx.guard.body;
- if (action === 'changePassword') {
- // Store password verification status
- const user = await findUserById(userId);
- assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
+ const user = await findUserById(userId);
+ assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
+
+ await verifyPasscode(undefined, codeType, verificationCode, identifier);
- await createVerificationStatus(userId);
- }
+ await createVerificationStatus(userId, identifier.email);
ctx.status = 204;
diff --git a/packages/schemas/alterations/next-1724316971-add-verified-identifier-to-verification-statuses.ts b/packages/schemas/alterations/next-1724316971-add-verified-identifier-to-verification-statuses.ts
new file mode 100644
index 00000000000..8009fd97308
--- /dev/null
+++ b/packages/schemas/alterations/next-1724316971-add-verified-identifier-to-verification-statuses.ts
@@ -0,0 +1,18 @@
+import { sql } from '@silverhand/slonik';
+
+import type { AlterationScript } from '../lib/types/alteration.js';
+
+const alteration: AlterationScript = {
+ up: async (pool) => {
+ await pool.query(sql`
+ alter table verification_statuses add column verified_identifier varchar(255);
+ `);
+ },
+ down: async (pool) => {
+ await pool.query(sql`
+ alter table verification_statuses drop column verified_identifier;
+ `);
+ },
+};
+
+export default alteration;
diff --git a/packages/schemas/tables/verification_statuses.sql b/packages/schemas/tables/verification_statuses.sql
index 1c9f6030082..4442988eb40 100644
--- a/packages/schemas/tables/verification_statuses.sql
+++ b/packages/schemas/tables/verification_statuses.sql
@@ -5,6 +5,7 @@ create table verification_statuses (
user_id varchar(21) not null
references users (id) on update cascade on delete cascade,
created_at timestamptz not null default(now()),
+ verified_identifier varchar(255),
primary key (id)
);