0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat: add phone number validation to user APIs

This commit is contained in:
Darcy Ye 2024-05-16 17:07:05 +08:00
parent 136320584f
commit 6cc51d098b
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
33 changed files with 273 additions and 141 deletions

View file

@ -86,7 +86,7 @@
"jest-transformer-svg": "^2.0.0",
"just-kebab-case": "^4.2.0",
"ky": "^1.2.3",
"libphonenumber-js": "^1.10.51",
"libphonenumber-js": "^1.11.1",
"lint-staged": "^15.0.0",
"nanoid": "^5.0.1",
"overlayscrollbars": "^2.0.2",

View file

@ -2,7 +2,7 @@ import { emailRegEx, usernameRegEx } from '@logto/core-kit';
import type { User } from '@logto/schemas';
import { parsePhoneNumber } from '@logto/shared/universal';
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 { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';

View file

@ -186,11 +186,15 @@ describe('verifyUserPassword()', () => {
};
it('migrates password to Argon2', async () => {
await verifyUserPassword(user, 'password');
expect(updateUserById).toHaveBeenCalledWith(user.id, {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
passwordEncrypted: expect.stringContaining('argon2'),
passwordEncryptionMethod: UsersPasswordEncryptionMethod.Argon2i,
});
expect(updateUserById).toHaveBeenCalledWith(
user.id,
{
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
passwordEncrypted: expect.stringContaining('argon2'),
passwordEncryptionMethod: UsersPasswordEncryptionMethod.Argon2i,
},
undefined
);
});
});
});
@ -220,6 +224,7 @@ describe('addUserMfaVerification()', () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date(createdAt));
jest.clearAllMocks();
});
afterAll(() => {
@ -227,10 +232,19 @@ describe('addUserMfaVerification()', () => {
});
it('update user with new mfa verification', async () => {
await addUserMfaVerification(mockUser.id, { type: MfaFactor.TOTP, secret: 'secret' });
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
mfaVerifications: [{ type: MfaFactor.TOTP, key: 'secret', id: expect.anything(), createdAt }],
await addUserMfaVerification(mockUser.id, {
type: MfaFactor.TOTP,
secret: 'secret',
});
expect(updateUserById).toHaveBeenCalledWith(
mockUser.id,
{
mfaVerifications: [
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
{ type: MfaFactor.TOTP, key: 'secret', id: expect.anything(), createdAt },
],
},
undefined
);
});
});

View file

@ -1,7 +1,8 @@
import type { BindMfa, CreateUser, MfaVerification, Scope, User } from '@logto/schemas';
import { MfaFactor, RoleType, Users, UsersPasswordEncryptionMethod } from '@logto/schemas';
import { generateStandardId, generateStandardShortId } from '@logto/shared';
import { deduplicateByKey, type Nullable } from '@silverhand/essentials';
import { generateStandardShortId, generateStandardId } from '@logto/shared';
import type { Nullable } from '@silverhand/essentials';
import { deduplicateByKey, conditional } from '@silverhand/essentials';
import { argon2Verify, bcryptVerify, md5, sha1, sha256 } from 'hash-wasm';
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 { encryptPassword } from '#src/utils/password.js';
import type { OmitAutoSetFields } from '#src/utils/sql.js';
import { getValidPhoneNumber } from '#src/utils/user.js';
export const encryptUserPassword = async (
password: string
@ -90,7 +92,7 @@ export const createUserLibrary = (queries: Queries) => {
hasUserWithId,
hasUserWithPhone,
findUsersByIds,
updateUserById,
updateUserById: updateUserByIdQuery,
findUserById,
},
usersRoles: { findUsersRolesByRoleId, findUsersRolesByUserId },
@ -115,6 +117,24 @@ export const createUserLibrary = (queries: Queries) => {
{ 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 (
data: OmitAutoSetFields<CreateUser>,
additionalRoleNames: string[]
@ -127,12 +147,21 @@ export const createUserLibrary = (queries: Queries) => {
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) => {
const insertUserQuery = buildInsertIntoWithPool(connection)(Users, {
returning: true,
});
const user = await insertUserQuery(data);
const user = await insertUserQuery({
...data,
...conditional(validPhoneNumber && { primaryPhone: validPhoneNumber }),
});
const roles = deduplicateByKey([...parameterRoles, ...defaultRoles], 'id');
if (roles.length > 0) {
@ -336,5 +365,6 @@ export const createUserLibrary = (queries: Queries) => {
verifyUserPassword,
signOutUser,
findUserSsoIdentities,
updateUserById,
};
};

View file

@ -22,9 +22,12 @@ export default function socialRoutes<T extends AuthedMeRouter>(
) {
const {
queries: {
users: { findUserById, updateUserById, deleteUserIdentity, hasUserWithIdentity },
users: { findUserById, deleteUserIdentity, hasUserWithIdentity },
signInExperiences: { findDefaultSignInExperience },
},
libraries: {
users: { updateUserById },
},
connectors: { getLogtoConnectors, getLogtoConnectorById },
} = tenant;

View file

@ -17,10 +17,10 @@ export default function userRoutes<T extends AuthedMeRouter>(
) {
const {
queries: {
users: { findUserById, updateUserById },
users: { findUserById },
},
libraries: {
users: { checkIdentifierCollision, verifyUserPassword },
users: { checkIdentifierCollision, verifyUserPassword, updateUserById },
verificationStatuses: { createVerificationStatus, checkVerificationStatus },
},
} = tenant;

View file

@ -35,12 +35,6 @@ const mockedQueries = {
hasUser: jest.fn(async () => mockHasUser()),
hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()),
hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()),
updateUserById: jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
),
deleteUserById: jest.fn(),
deleteUserIdentity: jest.fn(),
},
@ -67,8 +61,7 @@ const mockHasUser = jest.fn(async () => false);
const mockHasUserWithEmail = jest.fn(async () => false);
const mockHasUserWithPhone = jest.fn(async () => false);
const { hasUser, findUserById, updateUserById, deleteUserIdentity, deleteUserById } =
mockedQueries.users;
const { hasUser, findUserById, deleteUserIdentity, deleteUserById } = mockedQueries.users;
const { encryptUserPassword } = await mockEsmWithActual('#src/libraries/user.js', () => ({
encryptUserPassword: jest.fn(() => ({
@ -92,8 +85,16 @@ const usersLibraries = {
),
verifyUserPassword,
signOutUser,
updateUserById: jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
),
} satisfies Partial<Libraries['users']>;
const { updateUserById } = usersLibraries;
const adminUserRoutes = await pickDefault(import('./basics.js'));
describe('adminUserRoutes', () => {

View file

@ -23,14 +23,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
) {
const [router, { queries, libraries }] = args;
const {
users: {
deleteUserById,
findUserById,
hasUser,
updateUserById,
hasUserWithEmail,
hasUserWithPhone,
},
users: { deleteUserById, findUserById, hasUser, hasUserWithEmail, hasUserWithPhone },
} = queries;
const {
users: {
@ -40,6 +33,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
verifyUserPassword,
signOutUser,
findUserSsoIdentities,
updateUserById,
},
} = libraries;

View file

@ -8,7 +8,6 @@ import {
mockUserTotpMfaVerification,
mockUserWithMfaVerifications,
} from '#src/__mocks__/index.js';
import { type InsertUserResult } from '#src/libraries/user.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js';
@ -23,12 +22,6 @@ const mockedQueries = {
hasUser: jest.fn(async () => mockHasUser()),
hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()),
hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()),
updateUserById: jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
),
deleteUserById: jest.fn(),
deleteUserIdentity: jest.fn(),
},
@ -38,7 +31,7 @@ const mockHasUser = jest.fn(async () => false);
const mockHasUserWithEmail = 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', () => ({
generateTotpSecret: jest.fn().mockReturnValue('totp_secret'),
@ -47,25 +40,31 @@ await mockEsmWithActual('../interaction/utils/backup-code-validation.js', () =>
generateBackupCodes: jest.fn().mockReturnValue(['code']),
}));
const usersLibraries = {
generateUserId: jest.fn(async () => 'fooId'),
insertUser: jest.fn(
async (user: CreateUser): Promise<InsertUserResult> => [
{
const mockLibraries = {
users: {
generateUserId: jest.fn(async () => 'fooId'),
insertUser: jest.fn(
async (user: CreateUser): Promise<User> => ({
...mockUser,
...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'));
describe('adminUserRoutes', () => {
const tenantContext = new MockTenant(undefined, mockedQueries, undefined, {
users: usersLibraries,
});
const tenantContext = new MockTenant(undefined, mockedQueries, undefined, mockLibraries);
const userRequest = createRequester({ authedRoutes: adminUserRoutes, tenantContext });
afterEach(() => {

View file

@ -21,12 +21,12 @@ export default function adminUserMfaVerificationsRoutes<T extends ManagementApiR
{
queries,
libraries: {
users: { addUserMfaVerification },
users: { addUserMfaVerification, updateUserById },
},
},
] = args;
const {
users: { findUserById, updateUserById },
users: { findUserById },
} = queries;
router.get(

View file

@ -34,12 +34,6 @@ const mockedQueries = {
}
return mockUser;
}),
updateUserById: jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
),
hasUserWithIdentity: mockHasUserWithIdentity,
deleteUserById: jest.fn(),
deleteUserIdentity: jest.fn(),
@ -57,6 +51,12 @@ const usersLibraries = {
{ organizationIds: [] },
]
),
updateUserById: jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
),
} satisfies Partial<Libraries['users']>;
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'));

View file

@ -20,7 +20,10 @@ export default function adminUserSocialRoutes<T extends ManagementApiRouter>(
) {
const {
queries: {
users: { findUserById, updateUserById, hasUserWithIdentity, deleteUserIdentity },
users: { findUserById, hasUserWithIdentity, deleteUserIdentity },
},
libraries: {
users: { updateUserById },
},
connectors: { getLogtoConnectorById },
} = tenant;

View file

@ -44,19 +44,19 @@ const userQueries = {
identities: { google: { userId: 'googleId', details: {} } },
mfaVerifications: [],
}),
updateUserById: jest.fn(),
hasActiveUsers: jest.fn().mockResolvedValue(true),
hasUserWithEmail: jest.fn().mockResolvedValue(false),
hasUserWithPhone: jest.fn().mockResolvedValue(false),
};
const { hasActiveUsers, updateUserById } = userQueries;
const { hasActiveUsers } = userQueries;
const userLibraries = {
generateUserId: jest.fn().mockResolvedValue('uid'),
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 now = Date.now();

View file

@ -53,21 +53,21 @@ const userQueries = {
identities: { google: { userId: 'googleId', details: {} } },
mfaVerifications: [],
}),
updateUserById: jest.fn(async (id: string, user: Partial<User>) => user as User),
hasActiveUsers: jest.fn().mockResolvedValue(true),
hasUserWithEmail: jest.fn().mockResolvedValue(false),
hasUserWithPhone: jest.fn().mockResolvedValue(false),
};
const { hasActiveUsers, updateUserById, hasUserWithEmail, hasUserWithPhone } = userQueries;
const { hasActiveUsers, hasUserWithEmail, hasUserWithPhone } = userQueries;
const userLibraries = {
generateUserId: jest.fn().mockResolvedValue('uid'),
insertUser: jest.fn(
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 now = Date.now();

View file

@ -213,8 +213,9 @@ async function handleSubmitSignIn(
tenantContext: TenantContext,
log?: LogEntry
) {
const { provider, queries } = tenantContext;
const { findUserById, updateUserById } = queries.users;
const { provider, queries, libraries } = tenantContext;
const { findUserById } = queries.users;
const { updateUserById } = libraries.users;
const { accountId } = interaction;
log?.append({ userId: accountId });
@ -262,8 +263,8 @@ export default async function submitInteraction(
tenantContext: TenantContext,
log?: LogEntry
) {
const { provider, queries } = tenantContext;
const { updateUserById } = queries.users;
const { provider, libraries } = tenantContext;
const { updateUserById } = libraries.users;
const { event, profile } = interaction;
if (event === InteractionEvent.Register) {

View file

@ -76,6 +76,11 @@ const tenantContext = new MockTenant(
},
users: {
findUserById: jest.fn().mockResolvedValue(mockUserWithMfaVerifications),
},
},
undefined,
{
users: {
updateUserById,
},
}

View file

@ -30,7 +30,7 @@ export default function mfaRoutes<T extends IRouterParamContext>(
router: Router<unknown, WithInteractionDetailsContext<WithLogContext<T>>>,
tenant: TenantContext
) {
const { provider, queries } = tenant;
const { provider, queries, libraries } = tenant;
// Set New MFA
router.post(
@ -131,7 +131,7 @@ export default function mfaRoutes<T extends IRouterParamContext>(
// Update last used time
const user = await queries.users.findUserById(accountId);
await queries.users.updateUserById(accountId, {
await libraries.users.updateUserById(accountId, {
mfaVerifications: user.mfaVerifications.map((mfa) => {
if (mfa.id !== verifiedMfa.id) {
return mfa;

View file

@ -80,7 +80,6 @@ describe('Single sign on util methods tests', () => {
insert: insertUserSsoIdentityMock,
},
users: {
updateUserById: updateUserMock,
findUserByEmail: findUserByEmailMock,
},
},
@ -89,6 +88,7 @@ describe('Single sign on util methods tests', () => {
users: {
insertUser: insertUserMock,
generateUserId: generateUserIdMock,
updateUserById: updateUserMock,
},
ssoConnectors: {
getAvailableSsoConnectors: getAvailableSsoConnectorsMock,

View file

@ -14,6 +14,7 @@ import RequestError from '#src/errors/RequestError/index.js';
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import { type WithInteractionDetailsContext } from '#src/routes/interaction/middleware/koa-interaction-details.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 TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
@ -139,7 +140,7 @@ export const handleSsoAuthentication = async (
ssoAuthentication: SsoAuthenticationResult
): Promise<string> => {
const { createLog } = ctx;
const { provider, queries } = tenant;
const { provider, queries, libraries } = tenant;
const { userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries } = queries;
const { issuer, userInfo } = ssoAuthentication;
@ -151,7 +152,7 @@ export const handleSsoAuthentication = async (
// SignIn
if (userSsoIdentity) {
return signInWithSsoAuthentication(ctx, queries, {
return signInWithSsoAuthentication(ctx, queries, libraries, {
connectorData,
userSsoIdentity,
ssoAuthentication,
@ -162,7 +163,7 @@ export const handleSsoAuthentication = async (
// SignIn and link with existing user account with a same email
if (user) {
return signInAndLinkWithSsoAuthentication(ctx, queries, {
return signInAndLinkWithSsoAuthentication(ctx, queries, libraries, {
connectorData,
user,
ssoAuthentication,
@ -180,7 +181,8 @@ export const handleSsoAuthentication = async (
const signInWithSsoAuthentication = async (
ctx: WithLogContext,
{ userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries }: Queries,
{ userSsoIdentities: userSsoIdentitiesQueries }: Queries,
{ users: usersLibraries }: Libraries,
{
connectorData: { id: connectorId, syncProfile },
userSsoIdentity: { id, userId },
@ -211,7 +213,7 @@ const signInWithSsoAuthentication = async (
}
: undefined;
await usersQueries.updateUserById(userId, {
await usersLibraries.updateUserById(userId, {
...syncingProfile,
lastSignInAt: Date.now(),
});
@ -234,7 +236,8 @@ const signInWithSsoAuthentication = async (
const signInAndLinkWithSsoAuthentication = async (
ctx: WithLogContext,
{ userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries }: Queries,
{ userSsoIdentities: userSsoIdentitiesQueries }: Queries,
{ users: usersLibraries }: Libraries,
{
connectorData: { id: connectorId, syncProfile },
user: { id: userId },
@ -269,7 +272,7 @@ const signInAndLinkWithSsoAuthentication = async (
}
: undefined;
await usersQueries.updateUserById(userId, {
await usersLibraries.updateUserById(userId, {
...syncingProfile,
lastSignInAt: Date.now(),
});

View file

@ -24,12 +24,20 @@ const { mockEsm } = createMockUtils(jest);
const findUserById = jest.fn();
const updateUserById = jest.fn();
const tenantContext = new MockTenant(undefined, {
users: {
findUserById,
updateUserById,
const tenantContext = new MockTenant(
undefined,
{
users: {
findUserById,
},
},
});
undefined,
{
users: {
updateUserById,
},
}
);
const { validateTotpToken } = mockEsm('../utils/totp-validation.js', () => ({
validateTotpToken: jest.fn().mockReturnValue(true),

View file

@ -230,7 +230,7 @@ export async function verifyMfaPayloadVerification(
if (newCounter !== undefined) {
// 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) => {
if (mfa.type !== MfaFactor.WebAuthn || mfa.id !== result.id) {
return mfa;
@ -250,7 +250,7 @@ export async function verifyMfaPayloadVerification(
const { id, type } = await verifyBackupCode(user.mfaVerifications, verifyMfaPayload);
// Mark the backup code as used
await tenant.queries.users.updateUserById(accountId, {
await tenant.libraries.users.updateUserById(accountId, {
mfaVerifications: user.mfaVerifications.map((mfa) => {
if (mfa.id !== id || mfa.type !== MfaFactor.BackupCode) {
return mfa;

View file

@ -28,12 +28,20 @@ const { mockEsmWithActual } = createMockUtils(jest);
const findUserById = jest.fn();
const updateUserById = jest.fn();
const tenantContext = new MockTenant(undefined, {
users: {
findUserById,
updateUserById,
const tenantContext = new MockTenant(
undefined,
{
users: {
findUserById,
},
},
});
undefined,
{
users: {
updateUserById,
},
}
);
const mockBackupCodes = ['foo'];
await mockEsmWithActual('../utils/backup-code-validation.js', () => ({

View file

@ -28,12 +28,18 @@ const { mockEsmWithActual } = createMockUtils(jest);
const findUserById = jest.fn();
const updateUserById = jest.fn();
const tenantContext = new MockTenant(undefined, {
users: {
findUserById,
updateUserById,
const tenantContext = new MockTenant(
undefined,
{
users: {
findUserById,
},
},
});
undefined,
{
users: { updateUserById },
}
);
const mockBackupCodes = ['foo'];
await mockEsmWithActual('../utils/backup-code-validation.js', () => ({

View file

@ -1,4 +1,8 @@
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 = (
mfaVerifications: User['mfaVerifications']
@ -21,3 +25,25 @@ export const transpileUserMfaVerifications = (
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;
}
);

View file

@ -27,6 +27,7 @@
"@logto/phrases": "workspace:^1.11.0",
"@logto/phrases-experience": "workspace:^1.6.1",
"@logto/schemas": "workspace:^1.17.0",
"@logto/shared": "workspace:^3.1.0",
"@parcel/compressor-brotli": "2.9.3",
"@parcel/compressor-gzip": "2.9.3",
"@parcel/core": "2.9.3",
@ -67,7 +68,7 @@
"jest-transformer-svg": "^2.0.0",
"js-base64": "^3.7.5",
"ky": "^1.2.3",
"libphonenumber-js": "^1.10.51",
"libphonenumber-js": "^1.11.1",
"lint-staged": "^15.0.0",
"parcel": "2.9.3",
"parcel-resolver-ignore": "^2.1.3",

View file

@ -1,10 +1,7 @@
import { parseE164PhoneNumberWithError } from '@logto/shared/universal';
import i18next from 'i18next';
import type { CountryCode, CountryCallingCode, E164Number } from 'libphonenumber-js/mobile';
import {
getCountries,
getCountryCallingCode,
parsePhoneNumberWithError,
} from 'libphonenumber-js/mobile';
import type { CountryCode, CountryCallingCode } from 'libphonenumber-js/mobile';
import { getCountries, getCountryCallingCode } from 'libphonenumber-js/mobile';
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) => {
try {
const phoneNumber = parsePhoneNumberWithError(parseE164Number(number));
const phoneNumber = parseE164PhoneNumberWithError(number);
return `+${phoneNumber.countryCallingCode} ${phoneNumber.nationalNumber}`;
} catch {
@ -106,7 +95,7 @@ export const formatPhoneNumberWithCountryCallingCode = (number: string) => {
export const parsePhoneNumber = (value: string) => {
try {
const phoneNumber = parsePhoneNumberWithError(parseE164Number(value));
const phoneNumber = parseE164PhoneNumberWithError(value);
return {
countryCallingCode: phoneNumber.countryCallingCode,

View file

@ -1,12 +1,13 @@
import { usernameRegEx, emailRegEx } from '@logto/core-kit';
import { SignInIdentifier } from '@logto/schemas';
import { parseE164PhoneNumberWithError } from '@logto/shared/universal';
import i18next 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 { IdentifierInputType } from '@/components/InputFields/SmartInputField';
import { parseE164Number, parsePhoneNumber } from '@/utils/country-code';
import { parsePhoneNumber } from '@/utils/country-code';
const { t } = i18next;
@ -32,7 +33,7 @@ export const validateEmail = (email: string): ErrorType | undefined => {
export const validatePhone = (value: string): ErrorType | undefined => {
try {
const phoneNumber = parsePhoneNumberWithError(parseE164Number(value));
const phoneNumber = parseE164PhoneNumberWithError(value);
if (!phoneNumber.isValid()) {
return 'invalid_phone';

View file

@ -35,7 +35,6 @@ describe('admin console user search params', () => {
'jerry swift jr jr',
];
const emailSuffix = ['@gmail.com', '@foo.bar', '@geek.best'];
const phonePrefix = ['101', '102', '202'];
// eslint-disable-next-line @silverhand/fp/no-mutation
users = await Promise.all(
@ -47,8 +46,7 @@ describe('admin console user search params', () => {
.map((segment) => segment[0]!.toUpperCase() + segment.slice(1))
.join(' ');
const primaryEmail = username + emailSuffix[index % emailSuffix.length]!;
const primaryPhone =
phonePrefix[index % phonePrefix.length]! + index.toString().padStart(5, '0');
const primaryPhone = '1310805' + index.toString().padStart(4, '0');
return createUserByAdmin({ username: prefix + username, primaryEmail, primaryPhone, name });
})
@ -74,7 +72,7 @@ describe('admin console user search params', () => {
});
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(

View file

@ -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 () => {
const [username, primaryEmail, primaryPhone] = [
generateUsername(),

View file

@ -11,6 +11,7 @@ import {
} from '#src/api/role.js';
import { expectRejects } from '#src/helpers/index.js';
import { generateNewUserProfile } from '#src/helpers/user.js';
import { generatePhone } from '#src/utils.js';
describe('roles users', () => {
it('should get role users successfully and can get roles correctly (specifying exclude user)', async () => {
@ -38,7 +39,14 @@ describe('roles users', () => {
name: 'user001',
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' });
await assignUsersToRole([user1.id, user2.id, user3.id], role.id);

View file

@ -62,7 +62,7 @@
"@silverhand/essentials": "^2.9.1",
"chalk": "^5.0.0",
"find-up": "^7.0.0",
"libphonenumber-js": "^1.9.49",
"libphonenumber-js": "^1.11.1",
"nanoid": "^5.0.1"
}
}

View file

@ -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.
@ -6,7 +29,7 @@ import { parsePhoneNumberWithError } from 'libphonenumber-js';
*/
export const parsePhoneNumber = (phone: string) => {
try {
return parsePhoneNumberWithError(phone).number.slice(1);
return parseE164PhoneNumberWithError(phone).number.slice(1);
} catch {
console.error(`Invalid phone number: ${phone}`);
return phone;
@ -19,8 +42,7 @@ export const parsePhoneNumber = (phone: string) => {
*/
export const formatToInternationalPhoneNumber = (phone: string) => {
try {
const phoneNumber = phone.startsWith('+') ? phone : `+${phone}`;
return parsePhoneNumberWithError(phoneNumber).formatInternational();
return parseE164PhoneNumberWithError(phone).formatInternational();
} catch {
console.error(`Invalid phone number: ${phone}`);
return phone;

View file

@ -3032,8 +3032,8 @@ importers:
specifier: ^1.2.3
version: 1.2.3
libphonenumber-js:
specifier: ^1.10.51
version: 1.10.51
specifier: ^1.11.1
version: 1.11.1
lint-staged:
specifier: ^15.0.0
version: 15.0.2
@ -3563,6 +3563,9 @@ importers:
'@logto/schemas':
specifier: workspace:^1.17.0
version: link:../schemas
'@logto/shared':
specifier: workspace:^3.1.0
version: link:../shared
'@parcel/compressor-brotli':
specifier: 2.9.3
version: 2.9.3(@parcel/core@2.9.3)
@ -3684,8 +3687,8 @@ importers:
specifier: ^1.2.3
version: 1.2.3
libphonenumber-js:
specifier: ^1.10.51
version: 1.10.51
specifier: ^1.11.1
version: 1.11.1
lint-staged:
specifier: ^15.0.0
version: 15.0.2
@ -4012,8 +4015,8 @@ importers:
specifier: ^7.0.0
version: 7.0.0
libphonenumber-js:
specifier: ^1.9.49
version: 1.10.51
specifier: ^1.11.1
version: 1.11.1
nanoid:
specifier: ^5.0.1
version: 5.0.1
@ -9897,8 +9900,8 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
libphonenumber-js@1.10.51:
resolution: {integrity: sha512-vY2I+rQwrDQzoPds0JeTEpeWzbUJgqoV0O4v31PauHBb/e+1KCXKylHcDnBMgJZ9fH9mErsEbROJY3Z3JtqEmg==}
libphonenumber-js@1.11.1:
resolution: {integrity: sha512-Wze1LPwcnzvcKGcRHFGFECTaLzxOtujwpf924difr5zniyYv1C2PiW0419qDR7m8lKDxsImu5mwxFuXhXpjmvw==}
lightningcss-darwin-arm64@1.16.1:
resolution: {integrity: sha512-/J898YSAiGVqdybHdIF3Ao0Hbh2vyVVj5YNm3NznVzTSvkOi3qQCAtO97sfmNz+bSRHXga7ZPLm+89PpOM5gAg==}
@ -20790,7 +20793,7 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
libphonenumber-js@1.10.51: {}
libphonenumber-js@1.11.1: {}
lightningcss-darwin-arm64@1.16.1:
optional: true