mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat: add phone number validation to user APIs
This commit is contained in:
parent
136320584f
commit
6cc51d098b
33 changed files with 273 additions and 141 deletions
|
@ -86,7 +86,7 @@
|
||||||
"jest-transformer-svg": "^2.0.0",
|
"jest-transformer-svg": "^2.0.0",
|
||||||
"just-kebab-case": "^4.2.0",
|
"just-kebab-case": "^4.2.0",
|
||||||
"ky": "^1.2.3",
|
"ky": "^1.2.3",
|
||||||
"libphonenumber-js": "^1.10.51",
|
"libphonenumber-js": "^1.11.1",
|
||||||
"lint-staged": "^15.0.0",
|
"lint-staged": "^15.0.0",
|
||||||
"nanoid": "^5.0.1",
|
"nanoid": "^5.0.1",
|
||||||
"overlayscrollbars": "^2.0.2",
|
"overlayscrollbars": "^2.0.2",
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { emailRegEx, usernameRegEx } from '@logto/core-kit';
|
||||||
import type { User } from '@logto/schemas';
|
import type { User } from '@logto/schemas';
|
||||||
import { parsePhoneNumber } from '@logto/shared/universal';
|
import { parsePhoneNumber } from '@logto/shared/universal';
|
||||||
import { conditionalString, trySafe } from '@silverhand/essentials';
|
import { conditionalString, trySafe } from '@silverhand/essentials';
|
||||||
import { parsePhoneNumberWithError } from 'libphonenumber-js';
|
import { parsePhoneNumberWithError } from 'libphonenumber-js/mobile';
|
||||||
import { useForm, useController } from 'react-hook-form';
|
import { useForm, useController } from 'react-hook-form';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
|
@ -186,11 +186,15 @@ describe('verifyUserPassword()', () => {
|
||||||
};
|
};
|
||||||
it('migrates password to Argon2', async () => {
|
it('migrates password to Argon2', async () => {
|
||||||
await verifyUserPassword(user, 'password');
|
await verifyUserPassword(user, 'password');
|
||||||
expect(updateUserById).toHaveBeenCalledWith(user.id, {
|
expect(updateUserById).toHaveBeenCalledWith(
|
||||||
|
user.id,
|
||||||
|
{
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
passwordEncrypted: expect.stringContaining('argon2'),
|
passwordEncrypted: expect.stringContaining('argon2'),
|
||||||
passwordEncryptionMethod: UsersPasswordEncryptionMethod.Argon2i,
|
passwordEncryptionMethod: UsersPasswordEncryptionMethod.Argon2i,
|
||||||
});
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -220,6 +224,7 @@ describe('addUserMfaVerification()', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
jest.setSystemTime(new Date(createdAt));
|
jest.setSystemTime(new Date(createdAt));
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
@ -227,10 +232,19 @@ describe('addUserMfaVerification()', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('update user with new mfa verification', async () => {
|
it('update user with new mfa verification', async () => {
|
||||||
await addUserMfaVerification(mockUser.id, { type: MfaFactor.TOTP, secret: 'secret' });
|
await addUserMfaVerification(mockUser.id, {
|
||||||
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, {
|
type: MfaFactor.TOTP,
|
||||||
|
secret: 'secret',
|
||||||
|
});
|
||||||
|
expect(updateUserById).toHaveBeenCalledWith(
|
||||||
|
mockUser.id,
|
||||||
|
{
|
||||||
|
mfaVerifications: [
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
mfaVerifications: [{ type: MfaFactor.TOTP, key: 'secret', id: expect.anything(), createdAt }],
|
{ type: MfaFactor.TOTP, key: 'secret', id: expect.anything(), createdAt },
|
||||||
});
|
],
|
||||||
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import type { BindMfa, CreateUser, MfaVerification, Scope, User } from '@logto/schemas';
|
import type { BindMfa, CreateUser, MfaVerification, Scope, User } from '@logto/schemas';
|
||||||
import { MfaFactor, RoleType, Users, UsersPasswordEncryptionMethod } from '@logto/schemas';
|
import { MfaFactor, RoleType, Users, UsersPasswordEncryptionMethod } from '@logto/schemas';
|
||||||
import { generateStandardId, generateStandardShortId } from '@logto/shared';
|
import { generateStandardShortId, generateStandardId } from '@logto/shared';
|
||||||
import { deduplicateByKey, type Nullable } from '@silverhand/essentials';
|
import type { Nullable } from '@silverhand/essentials';
|
||||||
|
import { deduplicateByKey, conditional } from '@silverhand/essentials';
|
||||||
import { argon2Verify, bcryptVerify, md5, sha1, sha256 } from 'hash-wasm';
|
import { argon2Verify, bcryptVerify, md5, sha1, sha256 } from 'hash-wasm';
|
||||||
import pRetry from 'p-retry';
|
import pRetry from 'p-retry';
|
||||||
|
|
||||||
|
@ -14,6 +15,7 @@ import type Queries from '#src/tenants/Queries.js';
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
import { encryptPassword } from '#src/utils/password.js';
|
import { encryptPassword } from '#src/utils/password.js';
|
||||||
import type { OmitAutoSetFields } from '#src/utils/sql.js';
|
import type { OmitAutoSetFields } from '#src/utils/sql.js';
|
||||||
|
import { getValidPhoneNumber } from '#src/utils/user.js';
|
||||||
|
|
||||||
export const encryptUserPassword = async (
|
export const encryptUserPassword = async (
|
||||||
password: string
|
password: string
|
||||||
|
@ -90,7 +92,7 @@ export const createUserLibrary = (queries: Queries) => {
|
||||||
hasUserWithId,
|
hasUserWithId,
|
||||||
hasUserWithPhone,
|
hasUserWithPhone,
|
||||||
findUsersByIds,
|
findUsersByIds,
|
||||||
updateUserById,
|
updateUserById: updateUserByIdQuery,
|
||||||
findUserById,
|
findUserById,
|
||||||
},
|
},
|
||||||
usersRoles: { findUsersRolesByRoleId, findUsersRolesByUserId },
|
usersRoles: { findUsersRolesByRoleId, findUsersRolesByUserId },
|
||||||
|
@ -115,6 +117,24 @@ export const createUserLibrary = (queries: Queries) => {
|
||||||
{ retries, factor: 0 } // No need for exponential backoff
|
{ retries, factor: 0 } // No need for exponential backoff
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updateUserById = async (
|
||||||
|
id: string,
|
||||||
|
set: Partial<OmitAutoSetFields<CreateUser>>,
|
||||||
|
jsonbMode?: 'replace' | 'merge'
|
||||||
|
) => {
|
||||||
|
const validPhoneNumber = conditional(
|
||||||
|
'primaryPhone' in set &&
|
||||||
|
typeof set.primaryPhone === 'string' &&
|
||||||
|
getValidPhoneNumber(set.primaryPhone)
|
||||||
|
);
|
||||||
|
|
||||||
|
return updateUserByIdQuery(
|
||||||
|
id,
|
||||||
|
{ ...set, ...conditional(validPhoneNumber && { primaryPhone: validPhoneNumber }) },
|
||||||
|
jsonbMode
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const insertUser = async (
|
const insertUser = async (
|
||||||
data: OmitAutoSetFields<CreateUser>,
|
data: OmitAutoSetFields<CreateUser>,
|
||||||
additionalRoleNames: string[]
|
additionalRoleNames: string[]
|
||||||
|
@ -127,12 +147,21 @@ export const createUserLibrary = (queries: Queries) => {
|
||||||
|
|
||||||
assertThat(parameterRoles.length === roleNames.length, 'role.default_role_missing');
|
assertThat(parameterRoles.length === roleNames.length, 'role.default_role_missing');
|
||||||
|
|
||||||
|
const validPhoneNumber = conditional(
|
||||||
|
'primaryPhone' in data &&
|
||||||
|
typeof data.primaryPhone === 'string' &&
|
||||||
|
getValidPhoneNumber(data.primaryPhone)
|
||||||
|
);
|
||||||
|
|
||||||
return pool.transaction(async (connection) => {
|
return pool.transaction(async (connection) => {
|
||||||
const insertUserQuery = buildInsertIntoWithPool(connection)(Users, {
|
const insertUserQuery = buildInsertIntoWithPool(connection)(Users, {
|
||||||
returning: true,
|
returning: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = await insertUserQuery(data);
|
const user = await insertUserQuery({
|
||||||
|
...data,
|
||||||
|
...conditional(validPhoneNumber && { primaryPhone: validPhoneNumber }),
|
||||||
|
});
|
||||||
const roles = deduplicateByKey([...parameterRoles, ...defaultRoles], 'id');
|
const roles = deduplicateByKey([...parameterRoles, ...defaultRoles], 'id');
|
||||||
|
|
||||||
if (roles.length > 0) {
|
if (roles.length > 0) {
|
||||||
|
@ -336,5 +365,6 @@ export const createUserLibrary = (queries: Queries) => {
|
||||||
verifyUserPassword,
|
verifyUserPassword,
|
||||||
signOutUser,
|
signOutUser,
|
||||||
findUserSsoIdentities,
|
findUserSsoIdentities,
|
||||||
|
updateUserById,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -22,9 +22,12 @@ export default function socialRoutes<T extends AuthedMeRouter>(
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
queries: {
|
queries: {
|
||||||
users: { findUserById, updateUserById, deleteUserIdentity, hasUserWithIdentity },
|
users: { findUserById, deleteUserIdentity, hasUserWithIdentity },
|
||||||
signInExperiences: { findDefaultSignInExperience },
|
signInExperiences: { findDefaultSignInExperience },
|
||||||
},
|
},
|
||||||
|
libraries: {
|
||||||
|
users: { updateUserById },
|
||||||
|
},
|
||||||
connectors: { getLogtoConnectors, getLogtoConnectorById },
|
connectors: { getLogtoConnectors, getLogtoConnectorById },
|
||||||
} = tenant;
|
} = tenant;
|
||||||
|
|
||||||
|
|
|
@ -17,10 +17,10 @@ export default function userRoutes<T extends AuthedMeRouter>(
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
queries: {
|
queries: {
|
||||||
users: { findUserById, updateUserById },
|
users: { findUserById },
|
||||||
},
|
},
|
||||||
libraries: {
|
libraries: {
|
||||||
users: { checkIdentifierCollision, verifyUserPassword },
|
users: { checkIdentifierCollision, verifyUserPassword, updateUserById },
|
||||||
verificationStatuses: { createVerificationStatus, checkVerificationStatus },
|
verificationStatuses: { createVerificationStatus, checkVerificationStatus },
|
||||||
},
|
},
|
||||||
} = tenant;
|
} = tenant;
|
||||||
|
|
|
@ -35,12 +35,6 @@ const mockedQueries = {
|
||||||
hasUser: jest.fn(async () => mockHasUser()),
|
hasUser: jest.fn(async () => mockHasUser()),
|
||||||
hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()),
|
hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()),
|
||||||
hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()),
|
hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()),
|
||||||
updateUserById: jest.fn(
|
|
||||||
async (_, data: Partial<CreateUser>): Promise<User> => ({
|
|
||||||
...mockUser,
|
|
||||||
...data,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
deleteUserById: jest.fn(),
|
deleteUserById: jest.fn(),
|
||||||
deleteUserIdentity: jest.fn(),
|
deleteUserIdentity: jest.fn(),
|
||||||
},
|
},
|
||||||
|
@ -67,8 +61,7 @@ const mockHasUser = jest.fn(async () => false);
|
||||||
const mockHasUserWithEmail = jest.fn(async () => false);
|
const mockHasUserWithEmail = jest.fn(async () => false);
|
||||||
const mockHasUserWithPhone = jest.fn(async () => false);
|
const mockHasUserWithPhone = jest.fn(async () => false);
|
||||||
|
|
||||||
const { hasUser, findUserById, updateUserById, deleteUserIdentity, deleteUserById } =
|
const { hasUser, findUserById, deleteUserIdentity, deleteUserById } = mockedQueries.users;
|
||||||
mockedQueries.users;
|
|
||||||
|
|
||||||
const { encryptUserPassword } = await mockEsmWithActual('#src/libraries/user.js', () => ({
|
const { encryptUserPassword } = await mockEsmWithActual('#src/libraries/user.js', () => ({
|
||||||
encryptUserPassword: jest.fn(() => ({
|
encryptUserPassword: jest.fn(() => ({
|
||||||
|
@ -92,8 +85,16 @@ const usersLibraries = {
|
||||||
),
|
),
|
||||||
verifyUserPassword,
|
verifyUserPassword,
|
||||||
signOutUser,
|
signOutUser,
|
||||||
|
updateUserById: jest.fn(
|
||||||
|
async (_, data: Partial<CreateUser>): Promise<User> => ({
|
||||||
|
...mockUser,
|
||||||
|
...data,
|
||||||
|
})
|
||||||
|
),
|
||||||
} satisfies Partial<Libraries['users']>;
|
} satisfies Partial<Libraries['users']>;
|
||||||
|
|
||||||
|
const { updateUserById } = usersLibraries;
|
||||||
|
|
||||||
const adminUserRoutes = await pickDefault(import('./basics.js'));
|
const adminUserRoutes = await pickDefault(import('./basics.js'));
|
||||||
|
|
||||||
describe('adminUserRoutes', () => {
|
describe('adminUserRoutes', () => {
|
||||||
|
|
|
@ -23,14 +23,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
||||||
) {
|
) {
|
||||||
const [router, { queries, libraries }] = args;
|
const [router, { queries, libraries }] = args;
|
||||||
const {
|
const {
|
||||||
users: {
|
users: { deleteUserById, findUserById, hasUser, hasUserWithEmail, hasUserWithPhone },
|
||||||
deleteUserById,
|
|
||||||
findUserById,
|
|
||||||
hasUser,
|
|
||||||
updateUserById,
|
|
||||||
hasUserWithEmail,
|
|
||||||
hasUserWithPhone,
|
|
||||||
},
|
|
||||||
} = queries;
|
} = queries;
|
||||||
const {
|
const {
|
||||||
users: {
|
users: {
|
||||||
|
@ -40,6 +33,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
||||||
verifyUserPassword,
|
verifyUserPassword,
|
||||||
signOutUser,
|
signOutUser,
|
||||||
findUserSsoIdentities,
|
findUserSsoIdentities,
|
||||||
|
updateUserById,
|
||||||
},
|
},
|
||||||
} = libraries;
|
} = libraries;
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,6 @@ import {
|
||||||
mockUserTotpMfaVerification,
|
mockUserTotpMfaVerification,
|
||||||
mockUserWithMfaVerifications,
|
mockUserWithMfaVerifications,
|
||||||
} from '#src/__mocks__/index.js';
|
} from '#src/__mocks__/index.js';
|
||||||
import { type InsertUserResult } from '#src/libraries/user.js';
|
|
||||||
import type Libraries from '#src/tenants/Libraries.js';
|
import type Libraries from '#src/tenants/Libraries.js';
|
||||||
import type Queries from '#src/tenants/Queries.js';
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js';
|
import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js';
|
||||||
|
@ -23,12 +22,6 @@ const mockedQueries = {
|
||||||
hasUser: jest.fn(async () => mockHasUser()),
|
hasUser: jest.fn(async () => mockHasUser()),
|
||||||
hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()),
|
hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()),
|
||||||
hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()),
|
hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()),
|
||||||
updateUserById: jest.fn(
|
|
||||||
async (_, data: Partial<CreateUser>): Promise<User> => ({
|
|
||||||
...mockUser,
|
|
||||||
...data,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
deleteUserById: jest.fn(),
|
deleteUserById: jest.fn(),
|
||||||
deleteUserIdentity: jest.fn(),
|
deleteUserIdentity: jest.fn(),
|
||||||
},
|
},
|
||||||
|
@ -38,7 +31,7 @@ const mockHasUser = jest.fn(async () => false);
|
||||||
const mockHasUserWithEmail = jest.fn(async () => false);
|
const mockHasUserWithEmail = jest.fn(async () => false);
|
||||||
const mockHasUserWithPhone = jest.fn(async () => false);
|
const mockHasUserWithPhone = jest.fn(async () => false);
|
||||||
|
|
||||||
const { findUserById, updateUserById } = mockedQueries.users;
|
const { findUserById } = mockedQueries.users;
|
||||||
|
|
||||||
await mockEsmWithActual('../interaction/utils/totp-validation.js', () => ({
|
await mockEsmWithActual('../interaction/utils/totp-validation.js', () => ({
|
||||||
generateTotpSecret: jest.fn().mockReturnValue('totp_secret'),
|
generateTotpSecret: jest.fn().mockReturnValue('totp_secret'),
|
||||||
|
@ -47,25 +40,31 @@ await mockEsmWithActual('../interaction/utils/backup-code-validation.js', () =>
|
||||||
generateBackupCodes: jest.fn().mockReturnValue(['code']),
|
generateBackupCodes: jest.fn().mockReturnValue(['code']),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const usersLibraries = {
|
const mockLibraries = {
|
||||||
|
users: {
|
||||||
generateUserId: jest.fn(async () => 'fooId'),
|
generateUserId: jest.fn(async () => 'fooId'),
|
||||||
insertUser: jest.fn(
|
insertUser: jest.fn(
|
||||||
async (user: CreateUser): Promise<InsertUserResult> => [
|
async (user: CreateUser): Promise<User> => ({
|
||||||
{
|
|
||||||
...mockUser,
|
...mockUser,
|
||||||
...removeUndefinedKeys(user), // No undefined values will be returned from database
|
...removeUndefinedKeys(user), // No undefined values will be returned from database
|
||||||
},
|
})
|
||||||
{ organizationIds: [] },
|
|
||||||
]
|
|
||||||
),
|
),
|
||||||
} satisfies Partial<Libraries['users']>;
|
updateUserById: jest.fn(
|
||||||
|
async (_, data: Partial<CreateUser>): Promise<User> => ({
|
||||||
|
...mockUser,
|
||||||
|
...data,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
addUserMfaVerification: jest.fn(),
|
||||||
|
},
|
||||||
|
} satisfies Partial2<Libraries>;
|
||||||
|
|
||||||
|
const { updateUserById } = mockLibraries.users;
|
||||||
|
|
||||||
const adminUserRoutes = await pickDefault(import('./mfa-verifications.js'));
|
const adminUserRoutes = await pickDefault(import('./mfa-verifications.js'));
|
||||||
|
|
||||||
describe('adminUserRoutes', () => {
|
describe('adminUserRoutes', () => {
|
||||||
const tenantContext = new MockTenant(undefined, mockedQueries, undefined, {
|
const tenantContext = new MockTenant(undefined, mockedQueries, undefined, mockLibraries);
|
||||||
users: usersLibraries,
|
|
||||||
});
|
|
||||||
const userRequest = createRequester({ authedRoutes: adminUserRoutes, tenantContext });
|
const userRequest = createRequester({ authedRoutes: adminUserRoutes, tenantContext });
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
|
@ -21,12 +21,12 @@ export default function adminUserMfaVerificationsRoutes<T extends ManagementApiR
|
||||||
{
|
{
|
||||||
queries,
|
queries,
|
||||||
libraries: {
|
libraries: {
|
||||||
users: { addUserMfaVerification },
|
users: { addUserMfaVerification, updateUserById },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
] = args;
|
] = args;
|
||||||
const {
|
const {
|
||||||
users: { findUserById, updateUserById },
|
users: { findUserById },
|
||||||
} = queries;
|
} = queries;
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
|
|
|
@ -34,12 +34,6 @@ const mockedQueries = {
|
||||||
}
|
}
|
||||||
return mockUser;
|
return mockUser;
|
||||||
}),
|
}),
|
||||||
updateUserById: jest.fn(
|
|
||||||
async (_, data: Partial<CreateUser>): Promise<User> => ({
|
|
||||||
...mockUser,
|
|
||||||
...data,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
hasUserWithIdentity: mockHasUserWithIdentity,
|
hasUserWithIdentity: mockHasUserWithIdentity,
|
||||||
deleteUserById: jest.fn(),
|
deleteUserById: jest.fn(),
|
||||||
deleteUserIdentity: jest.fn(),
|
deleteUserIdentity: jest.fn(),
|
||||||
|
@ -57,6 +51,12 @@ const usersLibraries = {
|
||||||
{ organizationIds: [] },
|
{ organizationIds: [] },
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
updateUserById: jest.fn(
|
||||||
|
async (_, data: Partial<CreateUser>): Promise<User> => ({
|
||||||
|
...mockUser,
|
||||||
|
...data,
|
||||||
|
})
|
||||||
|
),
|
||||||
} satisfies Partial<Libraries['users']>;
|
} satisfies Partial<Libraries['users']>;
|
||||||
|
|
||||||
const mockGetLogtoConnectors = jest.fn(async () => mockLogtoConnectorList);
|
const mockGetLogtoConnectors = jest.fn(async () => mockLogtoConnectorList);
|
||||||
|
@ -78,7 +78,8 @@ const mockedConnectors = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const { findUserById, updateUserById, deleteUserIdentity } = mockedQueries.users;
|
const { findUserById, deleteUserIdentity } = mockedQueries.users;
|
||||||
|
const { updateUserById } = usersLibraries;
|
||||||
|
|
||||||
const adminUserSocialRoutes = await pickDefault(import('./social.js'));
|
const adminUserSocialRoutes = await pickDefault(import('./social.js'));
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,10 @@ export default function adminUserSocialRoutes<T extends ManagementApiRouter>(
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
queries: {
|
queries: {
|
||||||
users: { findUserById, updateUserById, hasUserWithIdentity, deleteUserIdentity },
|
users: { findUserById, hasUserWithIdentity, deleteUserIdentity },
|
||||||
|
},
|
||||||
|
libraries: {
|
||||||
|
users: { updateUserById },
|
||||||
},
|
},
|
||||||
connectors: { getLogtoConnectorById },
|
connectors: { getLogtoConnectorById },
|
||||||
} = tenant;
|
} = tenant;
|
||||||
|
|
|
@ -44,19 +44,19 @@ const userQueries = {
|
||||||
identities: { google: { userId: 'googleId', details: {} } },
|
identities: { google: { userId: 'googleId', details: {} } },
|
||||||
mfaVerifications: [],
|
mfaVerifications: [],
|
||||||
}),
|
}),
|
||||||
updateUserById: jest.fn(),
|
|
||||||
hasActiveUsers: jest.fn().mockResolvedValue(true),
|
hasActiveUsers: jest.fn().mockResolvedValue(true),
|
||||||
hasUserWithEmail: jest.fn().mockResolvedValue(false),
|
hasUserWithEmail: jest.fn().mockResolvedValue(false),
|
||||||
hasUserWithPhone: jest.fn().mockResolvedValue(false),
|
hasUserWithPhone: jest.fn().mockResolvedValue(false),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { hasActiveUsers, updateUserById } = userQueries;
|
const { hasActiveUsers } = userQueries;
|
||||||
|
|
||||||
const userLibraries = {
|
const userLibraries = {
|
||||||
generateUserId: jest.fn().mockResolvedValue('uid'),
|
generateUserId: jest.fn().mockResolvedValue('uid'),
|
||||||
insertUser: jest.fn().mockResolvedValue([{}, { organizationIds: [] }]),
|
insertUser: jest.fn().mockResolvedValue([{}, { organizationIds: [] }]),
|
||||||
|
updateUserById: jest.fn(),
|
||||||
};
|
};
|
||||||
const { generateUserId, insertUser } = userLibraries;
|
const { generateUserId, insertUser, updateUserById } = userLibraries;
|
||||||
|
|
||||||
const submitInteraction = await pickDefault(import('./submit-interaction.js'));
|
const submitInteraction = await pickDefault(import('./submit-interaction.js'));
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
|
@ -53,21 +53,21 @@ const userQueries = {
|
||||||
identities: { google: { userId: 'googleId', details: {} } },
|
identities: { google: { userId: 'googleId', details: {} } },
|
||||||
mfaVerifications: [],
|
mfaVerifications: [],
|
||||||
}),
|
}),
|
||||||
updateUserById: jest.fn(async (id: string, user: Partial<User>) => user as User),
|
|
||||||
hasActiveUsers: jest.fn().mockResolvedValue(true),
|
hasActiveUsers: jest.fn().mockResolvedValue(true),
|
||||||
hasUserWithEmail: jest.fn().mockResolvedValue(false),
|
hasUserWithEmail: jest.fn().mockResolvedValue(false),
|
||||||
hasUserWithPhone: jest.fn().mockResolvedValue(false),
|
hasUserWithPhone: jest.fn().mockResolvedValue(false),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { hasActiveUsers, updateUserById, hasUserWithEmail, hasUserWithPhone } = userQueries;
|
const { hasActiveUsers, hasUserWithEmail, hasUserWithPhone } = userQueries;
|
||||||
|
|
||||||
const userLibraries = {
|
const userLibraries = {
|
||||||
generateUserId: jest.fn().mockResolvedValue('uid'),
|
generateUserId: jest.fn().mockResolvedValue('uid'),
|
||||||
insertUser: jest.fn(
|
insertUser: jest.fn(
|
||||||
async (user: CreateUser): Promise<InsertUserResult> => [user as User, { organizationIds: [] }]
|
async (user: CreateUser): Promise<InsertUserResult> => [user as User, { organizationIds: [] }]
|
||||||
),
|
),
|
||||||
|
updateUserById: jest.fn(async (id: string, user: Partial<User>) => user as User),
|
||||||
};
|
};
|
||||||
const { generateUserId, insertUser } = userLibraries;
|
const { generateUserId, insertUser, updateUserById } = userLibraries;
|
||||||
|
|
||||||
const submitInteraction = await pickDefault(import('./submit-interaction.js'));
|
const submitInteraction = await pickDefault(import('./submit-interaction.js'));
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
|
@ -213,8 +213,9 @@ async function handleSubmitSignIn(
|
||||||
tenantContext: TenantContext,
|
tenantContext: TenantContext,
|
||||||
log?: LogEntry
|
log?: LogEntry
|
||||||
) {
|
) {
|
||||||
const { provider, queries } = tenantContext;
|
const { provider, queries, libraries } = tenantContext;
|
||||||
const { findUserById, updateUserById } = queries.users;
|
const { findUserById } = queries.users;
|
||||||
|
const { updateUserById } = libraries.users;
|
||||||
|
|
||||||
const { accountId } = interaction;
|
const { accountId } = interaction;
|
||||||
log?.append({ userId: accountId });
|
log?.append({ userId: accountId });
|
||||||
|
@ -262,8 +263,8 @@ export default async function submitInteraction(
|
||||||
tenantContext: TenantContext,
|
tenantContext: TenantContext,
|
||||||
log?: LogEntry
|
log?: LogEntry
|
||||||
) {
|
) {
|
||||||
const { provider, queries } = tenantContext;
|
const { provider, libraries } = tenantContext;
|
||||||
const { updateUserById } = queries.users;
|
const { updateUserById } = libraries.users;
|
||||||
const { event, profile } = interaction;
|
const { event, profile } = interaction;
|
||||||
|
|
||||||
if (event === InteractionEvent.Register) {
|
if (event === InteractionEvent.Register) {
|
||||||
|
|
|
@ -76,6 +76,11 @@ const tenantContext = new MockTenant(
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
findUserById: jest.fn().mockResolvedValue(mockUserWithMfaVerifications),
|
findUserById: jest.fn().mockResolvedValue(mockUserWithMfaVerifications),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
users: {
|
||||||
updateUserById,
|
updateUserById,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@ export default function mfaRoutes<T extends IRouterParamContext>(
|
||||||
router: Router<unknown, WithInteractionDetailsContext<WithLogContext<T>>>,
|
router: Router<unknown, WithInteractionDetailsContext<WithLogContext<T>>>,
|
||||||
tenant: TenantContext
|
tenant: TenantContext
|
||||||
) {
|
) {
|
||||||
const { provider, queries } = tenant;
|
const { provider, queries, libraries } = tenant;
|
||||||
|
|
||||||
// Set New MFA
|
// Set New MFA
|
||||||
router.post(
|
router.post(
|
||||||
|
@ -131,7 +131,7 @@ export default function mfaRoutes<T extends IRouterParamContext>(
|
||||||
|
|
||||||
// Update last used time
|
// Update last used time
|
||||||
const user = await queries.users.findUserById(accountId);
|
const user = await queries.users.findUserById(accountId);
|
||||||
await queries.users.updateUserById(accountId, {
|
await libraries.users.updateUserById(accountId, {
|
||||||
mfaVerifications: user.mfaVerifications.map((mfa) => {
|
mfaVerifications: user.mfaVerifications.map((mfa) => {
|
||||||
if (mfa.id !== verifiedMfa.id) {
|
if (mfa.id !== verifiedMfa.id) {
|
||||||
return mfa;
|
return mfa;
|
||||||
|
|
|
@ -80,7 +80,6 @@ describe('Single sign on util methods tests', () => {
|
||||||
insert: insertUserSsoIdentityMock,
|
insert: insertUserSsoIdentityMock,
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
updateUserById: updateUserMock,
|
|
||||||
findUserByEmail: findUserByEmailMock,
|
findUserByEmail: findUserByEmailMock,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -89,6 +88,7 @@ describe('Single sign on util methods tests', () => {
|
||||||
users: {
|
users: {
|
||||||
insertUser: insertUserMock,
|
insertUser: insertUserMock,
|
||||||
generateUserId: generateUserIdMock,
|
generateUserId: generateUserIdMock,
|
||||||
|
updateUserById: updateUserMock,
|
||||||
},
|
},
|
||||||
ssoConnectors: {
|
ssoConnectors: {
|
||||||
getAvailableSsoConnectors: getAvailableSsoConnectorsMock,
|
getAvailableSsoConnectors: getAvailableSsoConnectorsMock,
|
||||||
|
|
|
@ -14,6 +14,7 @@ import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
|
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||||
import { type WithInteractionDetailsContext } from '#src/routes/interaction/middleware/koa-interaction-details.js';
|
import { type WithInteractionDetailsContext } from '#src/routes/interaction/middleware/koa-interaction-details.js';
|
||||||
import { ssoConnectorFactories, type SingleSignOnConnectorSession } from '#src/sso/index.js';
|
import { ssoConnectorFactories, type SingleSignOnConnectorSession } from '#src/sso/index.js';
|
||||||
|
import type Libraries from '#src/tenants/Libraries.js';
|
||||||
import type Queries from '#src/tenants/Queries.js';
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
@ -139,7 +140,7 @@ export const handleSsoAuthentication = async (
|
||||||
ssoAuthentication: SsoAuthenticationResult
|
ssoAuthentication: SsoAuthenticationResult
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const { createLog } = ctx;
|
const { createLog } = ctx;
|
||||||
const { provider, queries } = tenant;
|
const { provider, queries, libraries } = tenant;
|
||||||
const { userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries } = queries;
|
const { userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries } = queries;
|
||||||
const { issuer, userInfo } = ssoAuthentication;
|
const { issuer, userInfo } = ssoAuthentication;
|
||||||
|
|
||||||
|
@ -151,7 +152,7 @@ export const handleSsoAuthentication = async (
|
||||||
|
|
||||||
// SignIn
|
// SignIn
|
||||||
if (userSsoIdentity) {
|
if (userSsoIdentity) {
|
||||||
return signInWithSsoAuthentication(ctx, queries, {
|
return signInWithSsoAuthentication(ctx, queries, libraries, {
|
||||||
connectorData,
|
connectorData,
|
||||||
userSsoIdentity,
|
userSsoIdentity,
|
||||||
ssoAuthentication,
|
ssoAuthentication,
|
||||||
|
@ -162,7 +163,7 @@ export const handleSsoAuthentication = async (
|
||||||
|
|
||||||
// SignIn and link with existing user account with a same email
|
// SignIn and link with existing user account with a same email
|
||||||
if (user) {
|
if (user) {
|
||||||
return signInAndLinkWithSsoAuthentication(ctx, queries, {
|
return signInAndLinkWithSsoAuthentication(ctx, queries, libraries, {
|
||||||
connectorData,
|
connectorData,
|
||||||
user,
|
user,
|
||||||
ssoAuthentication,
|
ssoAuthentication,
|
||||||
|
@ -180,7 +181,8 @@ export const handleSsoAuthentication = async (
|
||||||
|
|
||||||
const signInWithSsoAuthentication = async (
|
const signInWithSsoAuthentication = async (
|
||||||
ctx: WithLogContext,
|
ctx: WithLogContext,
|
||||||
{ userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries }: Queries,
|
{ userSsoIdentities: userSsoIdentitiesQueries }: Queries,
|
||||||
|
{ users: usersLibraries }: Libraries,
|
||||||
{
|
{
|
||||||
connectorData: { id: connectorId, syncProfile },
|
connectorData: { id: connectorId, syncProfile },
|
||||||
userSsoIdentity: { id, userId },
|
userSsoIdentity: { id, userId },
|
||||||
|
@ -211,7 +213,7 @@ const signInWithSsoAuthentication = async (
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
await usersQueries.updateUserById(userId, {
|
await usersLibraries.updateUserById(userId, {
|
||||||
...syncingProfile,
|
...syncingProfile,
|
||||||
lastSignInAt: Date.now(),
|
lastSignInAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
@ -234,7 +236,8 @@ const signInWithSsoAuthentication = async (
|
||||||
|
|
||||||
const signInAndLinkWithSsoAuthentication = async (
|
const signInAndLinkWithSsoAuthentication = async (
|
||||||
ctx: WithLogContext,
|
ctx: WithLogContext,
|
||||||
{ userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries }: Queries,
|
{ userSsoIdentities: userSsoIdentitiesQueries }: Queries,
|
||||||
|
{ users: usersLibraries }: Libraries,
|
||||||
{
|
{
|
||||||
connectorData: { id: connectorId, syncProfile },
|
connectorData: { id: connectorId, syncProfile },
|
||||||
user: { id: userId },
|
user: { id: userId },
|
||||||
|
@ -269,7 +272,7 @@ const signInAndLinkWithSsoAuthentication = async (
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
await usersQueries.updateUserById(userId, {
|
await usersLibraries.updateUserById(userId, {
|
||||||
...syncingProfile,
|
...syncingProfile,
|
||||||
lastSignInAt: Date.now(),
|
lastSignInAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
|
@ -24,12 +24,20 @@ const { mockEsm } = createMockUtils(jest);
|
||||||
const findUserById = jest.fn();
|
const findUserById = jest.fn();
|
||||||
const updateUserById = jest.fn();
|
const updateUserById = jest.fn();
|
||||||
|
|
||||||
const tenantContext = new MockTenant(undefined, {
|
const tenantContext = new MockTenant(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
users: {
|
users: {
|
||||||
findUserById,
|
findUserById,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
users: {
|
||||||
updateUserById,
|
updateUserById,
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const { validateTotpToken } = mockEsm('../utils/totp-validation.js', () => ({
|
const { validateTotpToken } = mockEsm('../utils/totp-validation.js', () => ({
|
||||||
validateTotpToken: jest.fn().mockReturnValue(true),
|
validateTotpToken: jest.fn().mockReturnValue(true),
|
||||||
|
|
|
@ -230,7 +230,7 @@ export async function verifyMfaPayloadVerification(
|
||||||
|
|
||||||
if (newCounter !== undefined) {
|
if (newCounter !== undefined) {
|
||||||
// Update the authenticator's counter in the DB to the newest count in the authentication
|
// Update the authenticator's counter in the DB to the newest count in the authentication
|
||||||
await tenant.queries.users.updateUserById(accountId, {
|
await tenant.libraries.users.updateUserById(accountId, {
|
||||||
mfaVerifications: user.mfaVerifications.map((mfa) => {
|
mfaVerifications: user.mfaVerifications.map((mfa) => {
|
||||||
if (mfa.type !== MfaFactor.WebAuthn || mfa.id !== result.id) {
|
if (mfa.type !== MfaFactor.WebAuthn || mfa.id !== result.id) {
|
||||||
return mfa;
|
return mfa;
|
||||||
|
@ -250,7 +250,7 @@ export async function verifyMfaPayloadVerification(
|
||||||
const { id, type } = await verifyBackupCode(user.mfaVerifications, verifyMfaPayload);
|
const { id, type } = await verifyBackupCode(user.mfaVerifications, verifyMfaPayload);
|
||||||
|
|
||||||
// Mark the backup code as used
|
// Mark the backup code as used
|
||||||
await tenant.queries.users.updateUserById(accountId, {
|
await tenant.libraries.users.updateUserById(accountId, {
|
||||||
mfaVerifications: user.mfaVerifications.map((mfa) => {
|
mfaVerifications: user.mfaVerifications.map((mfa) => {
|
||||||
if (mfa.id !== id || mfa.type !== MfaFactor.BackupCode) {
|
if (mfa.id !== id || mfa.type !== MfaFactor.BackupCode) {
|
||||||
return mfa;
|
return mfa;
|
||||||
|
|
|
@ -28,12 +28,20 @@ const { mockEsmWithActual } = createMockUtils(jest);
|
||||||
const findUserById = jest.fn();
|
const findUserById = jest.fn();
|
||||||
const updateUserById = jest.fn();
|
const updateUserById = jest.fn();
|
||||||
|
|
||||||
const tenantContext = new MockTenant(undefined, {
|
const tenantContext = new MockTenant(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
users: {
|
users: {
|
||||||
findUserById,
|
findUserById,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
users: {
|
||||||
updateUserById,
|
updateUserById,
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const mockBackupCodes = ['foo'];
|
const mockBackupCodes = ['foo'];
|
||||||
await mockEsmWithActual('../utils/backup-code-validation.js', () => ({
|
await mockEsmWithActual('../utils/backup-code-validation.js', () => ({
|
||||||
|
|
|
@ -28,12 +28,18 @@ const { mockEsmWithActual } = createMockUtils(jest);
|
||||||
const findUserById = jest.fn();
|
const findUserById = jest.fn();
|
||||||
const updateUserById = jest.fn();
|
const updateUserById = jest.fn();
|
||||||
|
|
||||||
const tenantContext = new MockTenant(undefined, {
|
const tenantContext = new MockTenant(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
users: {
|
users: {
|
||||||
findUserById,
|
findUserById,
|
||||||
updateUserById,
|
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
users: { updateUserById },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const mockBackupCodes = ['foo'];
|
const mockBackupCodes = ['foo'];
|
||||||
await mockEsmWithActual('../utils/backup-code-validation.js', () => ({
|
await mockEsmWithActual('../utils/backup-code-validation.js', () => ({
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
import { MfaFactor, type User, type UserMfaVerificationResponse } from '@logto/schemas';
|
import { MfaFactor, type User, type UserMfaVerificationResponse } from '@logto/schemas';
|
||||||
|
import { parseE164PhoneNumberWithError, ParseError } from '@logto/shared/universal';
|
||||||
|
import { tryThat } from '@silverhand/essentials';
|
||||||
|
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
|
||||||
export const transpileUserMfaVerifications = (
|
export const transpileUserMfaVerifications = (
|
||||||
mfaVerifications: User['mfaVerifications']
|
mfaVerifications: User['mfaVerifications']
|
||||||
|
@ -21,3 +25,25 @@ export const transpileUserMfaVerifications = (
|
||||||
return { id, createdAt, type };
|
return { id, createdAt, type };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getValidPhoneNumber = (phone: string) =>
|
||||||
|
tryThat(
|
||||||
|
() => {
|
||||||
|
if (!phone) {
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
const phoneNumber = parseE164PhoneNumberWithError(phone);
|
||||||
|
if (!phoneNumber.isValid()) {
|
||||||
|
throw new RequestError({ code: 'user.invalid_phone', status: 422 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return phoneNumber.number.slice(1);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
if (error instanceof ParseError) {
|
||||||
|
throw new RequestError({ code: 'user.invalid_phone', status: 422 }, error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
"@logto/phrases": "workspace:^1.11.0",
|
"@logto/phrases": "workspace:^1.11.0",
|
||||||
"@logto/phrases-experience": "workspace:^1.6.1",
|
"@logto/phrases-experience": "workspace:^1.6.1",
|
||||||
"@logto/schemas": "workspace:^1.17.0",
|
"@logto/schemas": "workspace:^1.17.0",
|
||||||
|
"@logto/shared": "workspace:^3.1.0",
|
||||||
"@parcel/compressor-brotli": "2.9.3",
|
"@parcel/compressor-brotli": "2.9.3",
|
||||||
"@parcel/compressor-gzip": "2.9.3",
|
"@parcel/compressor-gzip": "2.9.3",
|
||||||
"@parcel/core": "2.9.3",
|
"@parcel/core": "2.9.3",
|
||||||
|
@ -67,7 +68,7 @@
|
||||||
"jest-transformer-svg": "^2.0.0",
|
"jest-transformer-svg": "^2.0.0",
|
||||||
"js-base64": "^3.7.5",
|
"js-base64": "^3.7.5",
|
||||||
"ky": "^1.2.3",
|
"ky": "^1.2.3",
|
||||||
"libphonenumber-js": "^1.10.51",
|
"libphonenumber-js": "^1.11.1",
|
||||||
"lint-staged": "^15.0.0",
|
"lint-staged": "^15.0.0",
|
||||||
"parcel": "2.9.3",
|
"parcel": "2.9.3",
|
||||||
"parcel-resolver-ignore": "^2.1.3",
|
"parcel-resolver-ignore": "^2.1.3",
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
|
import { parseE164PhoneNumberWithError } from '@logto/shared/universal';
|
||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
import type { CountryCode, CountryCallingCode, E164Number } from 'libphonenumber-js/mobile';
|
import type { CountryCode, CountryCallingCode } from 'libphonenumber-js/mobile';
|
||||||
import {
|
import { getCountries, getCountryCallingCode } from 'libphonenumber-js/mobile';
|
||||||
getCountries,
|
|
||||||
getCountryCallingCode,
|
|
||||||
parsePhoneNumberWithError,
|
|
||||||
} from 'libphonenumber-js/mobile';
|
|
||||||
|
|
||||||
export const fallbackCountryCode = 'US';
|
export const fallbackCountryCode = 'US';
|
||||||
|
|
||||||
|
@ -86,17 +83,9 @@ export const getCountryList = (): CountryMetaData[] => {
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const parseE164Number = (value: string): E164Number | '' => {
|
|
||||||
if (!value || value.startsWith('+')) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `+${value}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatPhoneNumberWithCountryCallingCode = (number: string) => {
|
export const formatPhoneNumberWithCountryCallingCode = (number: string) => {
|
||||||
try {
|
try {
|
||||||
const phoneNumber = parsePhoneNumberWithError(parseE164Number(number));
|
const phoneNumber = parseE164PhoneNumberWithError(number);
|
||||||
|
|
||||||
return `+${phoneNumber.countryCallingCode} ${phoneNumber.nationalNumber}`;
|
return `+${phoneNumber.countryCallingCode} ${phoneNumber.nationalNumber}`;
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -106,7 +95,7 @@ export const formatPhoneNumberWithCountryCallingCode = (number: string) => {
|
||||||
|
|
||||||
export const parsePhoneNumber = (value: string) => {
|
export const parsePhoneNumber = (value: string) => {
|
||||||
try {
|
try {
|
||||||
const phoneNumber = parsePhoneNumberWithError(parseE164Number(value));
|
const phoneNumber = parseE164PhoneNumberWithError(value);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
countryCallingCode: phoneNumber.countryCallingCode,
|
countryCallingCode: phoneNumber.countryCallingCode,
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import { usernameRegEx, emailRegEx } from '@logto/core-kit';
|
import { usernameRegEx, emailRegEx } from '@logto/core-kit';
|
||||||
import { SignInIdentifier } from '@logto/schemas';
|
import { SignInIdentifier } from '@logto/schemas';
|
||||||
|
import { parseE164PhoneNumberWithError } from '@logto/shared/universal';
|
||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
import type { TFuncKey } from 'i18next';
|
import type { TFuncKey } from 'i18next';
|
||||||
import { parsePhoneNumberWithError, ParseError } from 'libphonenumber-js/mobile';
|
import { ParseError } from 'libphonenumber-js/mobile';
|
||||||
|
|
||||||
import type { ErrorType } from '@/components/ErrorMessage';
|
import type { ErrorType } from '@/components/ErrorMessage';
|
||||||
import type { IdentifierInputType } from '@/components/InputFields/SmartInputField';
|
import type { IdentifierInputType } from '@/components/InputFields/SmartInputField';
|
||||||
import { parseE164Number, parsePhoneNumber } from '@/utils/country-code';
|
import { parsePhoneNumber } from '@/utils/country-code';
|
||||||
|
|
||||||
const { t } = i18next;
|
const { t } = i18next;
|
||||||
|
|
||||||
|
@ -32,7 +33,7 @@ export const validateEmail = (email: string): ErrorType | undefined => {
|
||||||
|
|
||||||
export const validatePhone = (value: string): ErrorType | undefined => {
|
export const validatePhone = (value: string): ErrorType | undefined => {
|
||||||
try {
|
try {
|
||||||
const phoneNumber = parsePhoneNumberWithError(parseE164Number(value));
|
const phoneNumber = parseE164PhoneNumberWithError(value);
|
||||||
|
|
||||||
if (!phoneNumber.isValid()) {
|
if (!phoneNumber.isValid()) {
|
||||||
return 'invalid_phone';
|
return 'invalid_phone';
|
||||||
|
|
|
@ -35,7 +35,6 @@ describe('admin console user search params', () => {
|
||||||
'jerry swift jr jr',
|
'jerry swift jr jr',
|
||||||
];
|
];
|
||||||
const emailSuffix = ['@gmail.com', '@foo.bar', '@geek.best'];
|
const emailSuffix = ['@gmail.com', '@foo.bar', '@geek.best'];
|
||||||
const phonePrefix = ['101', '102', '202'];
|
|
||||||
|
|
||||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||||
users = await Promise.all(
|
users = await Promise.all(
|
||||||
|
@ -47,8 +46,7 @@ describe('admin console user search params', () => {
|
||||||
.map((segment) => segment[0]!.toUpperCase() + segment.slice(1))
|
.map((segment) => segment[0]!.toUpperCase() + segment.slice(1))
|
||||||
.join(' ');
|
.join(' ');
|
||||||
const primaryEmail = username + emailSuffix[index % emailSuffix.length]!;
|
const primaryEmail = username + emailSuffix[index % emailSuffix.length]!;
|
||||||
const primaryPhone =
|
const primaryPhone = '1310805' + index.toString().padStart(4, '0');
|
||||||
phonePrefix[index % phonePrefix.length]! + index.toString().padStart(5, '0');
|
|
||||||
|
|
||||||
return createUserByAdmin({ username: prefix + username, primaryEmail, primaryPhone, name });
|
return createUserByAdmin({ username: prefix + username, primaryEmail, primaryPhone, name });
|
||||||
})
|
})
|
||||||
|
@ -74,7 +72,7 @@ describe('admin console user search params', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should search primaryPhone', async () => {
|
it('should search primaryPhone', async () => {
|
||||||
const { headers, json } = await getUsers<User[]>([['search', '%0000%']]);
|
const { headers, json } = await getUsers<User[]>([['search', '%000%']]);
|
||||||
|
|
||||||
expect(headers.get('total-number')).toEqual('10');
|
expect(headers.get('total-number')).toEqual('10');
|
||||||
expect(
|
expect(
|
||||||
|
|
|
@ -155,6 +155,14 @@ describe('admin console user management', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should respond 422 when update user with invalid phone', async () => {
|
||||||
|
const user = await createUserByAdmin();
|
||||||
|
await expectRejects(updateUser(user.id, { primaryPhone: '13110000000' }), {
|
||||||
|
code: 'user.invalid_phone',
|
||||||
|
status: 422,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should fail when update userinfo with conflict identifiers', async () => {
|
it('should fail when update userinfo with conflict identifiers', async () => {
|
||||||
const [username, primaryEmail, primaryPhone] = [
|
const [username, primaryEmail, primaryPhone] = [
|
||||||
generateUsername(),
|
generateUsername(),
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
} from '#src/api/role.js';
|
} from '#src/api/role.js';
|
||||||
import { expectRejects } from '#src/helpers/index.js';
|
import { expectRejects } from '#src/helpers/index.js';
|
||||||
import { generateNewUserProfile } from '#src/helpers/user.js';
|
import { generateNewUserProfile } from '#src/helpers/user.js';
|
||||||
|
import { generatePhone } from '#src/utils.js';
|
||||||
|
|
||||||
describe('roles users', () => {
|
describe('roles users', () => {
|
||||||
it('should get role users successfully and can get roles correctly (specifying exclude user)', async () => {
|
it('should get role users successfully and can get roles correctly (specifying exclude user)', async () => {
|
||||||
|
@ -38,7 +39,14 @@ describe('roles users', () => {
|
||||||
name: 'user001',
|
name: 'user001',
|
||||||
primaryEmail: 'user001@logto.io',
|
primaryEmail: 'user001@logto.io',
|
||||||
});
|
});
|
||||||
const user2 = await createUser({ name: 'user002', primaryPhone: '123456789' });
|
|
||||||
|
// Can not create user with invalid phone number.
|
||||||
|
await expectRejects(createUser({ name: 'user002', primaryPhone: '123456789' }), {
|
||||||
|
code: 'user.invalid_phone',
|
||||||
|
status: 422,
|
||||||
|
});
|
||||||
|
const user2 = await createUser({ name: 'user002', primaryPhone: generatePhone() });
|
||||||
|
|
||||||
const user3 = await createUser({ username: 'username3', primaryEmail: 'user3@logto.io' });
|
const user3 = await createUser({ username: 'username3', primaryEmail: 'user3@logto.io' });
|
||||||
await assignUsersToRole([user1.id, user2.id, user3.id], role.id);
|
await assignUsersToRole([user1.id, user2.id, user3.id], role.id);
|
||||||
|
|
||||||
|
|
|
@ -62,7 +62,7 @@
|
||||||
"@silverhand/essentials": "^2.9.1",
|
"@silverhand/essentials": "^2.9.1",
|
||||||
"chalk": "^5.0.0",
|
"chalk": "^5.0.0",
|
||||||
"find-up": "^7.0.0",
|
"find-up": "^7.0.0",
|
||||||
"libphonenumber-js": "^1.9.49",
|
"libphonenumber-js": "^1.11.1",
|
||||||
"nanoid": "^5.0.1"
|
"nanoid": "^5.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,27 @@
|
||||||
import { parsePhoneNumberWithError } from 'libphonenumber-js';
|
import { parsePhoneNumberWithError } from 'libphonenumber-js/mobile';
|
||||||
|
import type { E164Number } from 'libphonenumber-js/mobile';
|
||||||
|
|
||||||
|
export { ParseError } from 'libphonenumber-js/mobile';
|
||||||
|
|
||||||
|
function validateE164Number(value: string): asserts value is E164Number {
|
||||||
|
if (value && !value.startsWith('+')) {
|
||||||
|
throw new TypeError(`Invalid E164Number: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseE164Number = (value: string): E164Number | '' => {
|
||||||
|
// If typeof `value` is string and `!value` is true, then `value` is an empty string.
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = value.startsWith('+') ? value : `+${value}`;
|
||||||
|
validateE164Number(result);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseE164PhoneNumberWithError = (value: string) =>
|
||||||
|
parsePhoneNumberWithError(parseE164Number(value));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse phone number to number string.
|
* Parse phone number to number string.
|
||||||
|
@ -6,7 +29,7 @@ import { parsePhoneNumberWithError } from 'libphonenumber-js';
|
||||||
*/
|
*/
|
||||||
export const parsePhoneNumber = (phone: string) => {
|
export const parsePhoneNumber = (phone: string) => {
|
||||||
try {
|
try {
|
||||||
return parsePhoneNumberWithError(phone).number.slice(1);
|
return parseE164PhoneNumberWithError(phone).number.slice(1);
|
||||||
} catch {
|
} catch {
|
||||||
console.error(`Invalid phone number: ${phone}`);
|
console.error(`Invalid phone number: ${phone}`);
|
||||||
return phone;
|
return phone;
|
||||||
|
@ -19,8 +42,7 @@ export const parsePhoneNumber = (phone: string) => {
|
||||||
*/
|
*/
|
||||||
export const formatToInternationalPhoneNumber = (phone: string) => {
|
export const formatToInternationalPhoneNumber = (phone: string) => {
|
||||||
try {
|
try {
|
||||||
const phoneNumber = phone.startsWith('+') ? phone : `+${phone}`;
|
return parseE164PhoneNumberWithError(phone).formatInternational();
|
||||||
return parsePhoneNumberWithError(phoneNumber).formatInternational();
|
|
||||||
} catch {
|
} catch {
|
||||||
console.error(`Invalid phone number: ${phone}`);
|
console.error(`Invalid phone number: ${phone}`);
|
||||||
return phone;
|
return phone;
|
||||||
|
|
|
@ -3032,8 +3032,8 @@ importers:
|
||||||
specifier: ^1.2.3
|
specifier: ^1.2.3
|
||||||
version: 1.2.3
|
version: 1.2.3
|
||||||
libphonenumber-js:
|
libphonenumber-js:
|
||||||
specifier: ^1.10.51
|
specifier: ^1.11.1
|
||||||
version: 1.10.51
|
version: 1.11.1
|
||||||
lint-staged:
|
lint-staged:
|
||||||
specifier: ^15.0.0
|
specifier: ^15.0.0
|
||||||
version: 15.0.2
|
version: 15.0.2
|
||||||
|
@ -3563,6 +3563,9 @@ importers:
|
||||||
'@logto/schemas':
|
'@logto/schemas':
|
||||||
specifier: workspace:^1.17.0
|
specifier: workspace:^1.17.0
|
||||||
version: link:../schemas
|
version: link:../schemas
|
||||||
|
'@logto/shared':
|
||||||
|
specifier: workspace:^3.1.0
|
||||||
|
version: link:../shared
|
||||||
'@parcel/compressor-brotli':
|
'@parcel/compressor-brotli':
|
||||||
specifier: 2.9.3
|
specifier: 2.9.3
|
||||||
version: 2.9.3(@parcel/core@2.9.3)
|
version: 2.9.3(@parcel/core@2.9.3)
|
||||||
|
@ -3684,8 +3687,8 @@ importers:
|
||||||
specifier: ^1.2.3
|
specifier: ^1.2.3
|
||||||
version: 1.2.3
|
version: 1.2.3
|
||||||
libphonenumber-js:
|
libphonenumber-js:
|
||||||
specifier: ^1.10.51
|
specifier: ^1.11.1
|
||||||
version: 1.10.51
|
version: 1.11.1
|
||||||
lint-staged:
|
lint-staged:
|
||||||
specifier: ^15.0.0
|
specifier: ^15.0.0
|
||||||
version: 15.0.2
|
version: 15.0.2
|
||||||
|
@ -4012,8 +4015,8 @@ importers:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.0.0
|
version: 7.0.0
|
||||||
libphonenumber-js:
|
libphonenumber-js:
|
||||||
specifier: ^1.9.49
|
specifier: ^1.11.1
|
||||||
version: 1.10.51
|
version: 1.11.1
|
||||||
nanoid:
|
nanoid:
|
||||||
specifier: ^5.0.1
|
specifier: ^5.0.1
|
||||||
version: 5.0.1
|
version: 5.0.1
|
||||||
|
@ -9897,8 +9900,8 @@ packages:
|
||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
libphonenumber-js@1.10.51:
|
libphonenumber-js@1.11.1:
|
||||||
resolution: {integrity: sha512-vY2I+rQwrDQzoPds0JeTEpeWzbUJgqoV0O4v31PauHBb/e+1KCXKylHcDnBMgJZ9fH9mErsEbROJY3Z3JtqEmg==}
|
resolution: {integrity: sha512-Wze1LPwcnzvcKGcRHFGFECTaLzxOtujwpf924difr5zniyYv1C2PiW0419qDR7m8lKDxsImu5mwxFuXhXpjmvw==}
|
||||||
|
|
||||||
lightningcss-darwin-arm64@1.16.1:
|
lightningcss-darwin-arm64@1.16.1:
|
||||||
resolution: {integrity: sha512-/J898YSAiGVqdybHdIF3Ao0Hbh2vyVVj5YNm3NznVzTSvkOi3qQCAtO97sfmNz+bSRHXga7ZPLm+89PpOM5gAg==}
|
resolution: {integrity: sha512-/J898YSAiGVqdybHdIF3Ao0Hbh2vyVVj5YNm3NznVzTSvkOi3qQCAtO97sfmNz+bSRHXga7ZPLm+89PpOM5gAg==}
|
||||||
|
@ -20790,7 +20793,7 @@ snapshots:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
libphonenumber-js@1.10.51: {}
|
libphonenumber-js@1.11.1: {}
|
||||||
|
|
||||||
lightningcss-darwin-arm64@1.16.1:
|
lightningcss-darwin-arm64@1.16.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
Loading…
Reference in a new issue