Skip to content

Commit 35bbc43

Browse files
authored
feat: add phone number validation to user APIs (#5882)
1 parent 2759ff8 commit 35bbc43

File tree

17 files changed

+285
-72
lines changed

17 files changed

+285
-72
lines changed

.changeset/big-games-deny.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@logto/integration-tests": minor
3+
"@logto/experience": minor
4+
"@logto/console": minor
5+
"@logto/shared": minor
6+
"@logto/core": minor
7+
---
8+
9+
add phone number validation and parsing to ensure the correct format when updating an existing user’s primary phone number or creating a new user with a phone number

packages/console/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
"jest-transformer-svg": "^2.0.0",
8181
"just-kebab-case": "^4.2.0",
8282
"ky": "^1.2.3",
83-
"libphonenumber-js": "^1.10.51",
83+
"libphonenumber-js": "^1.12.6",
8484
"lint-staged": "^15.0.0",
8585
"mermaid": "^10.9.1",
8686
"nanoid": "^5.0.9",

packages/console/src/pages/UserDetails/UserSettings/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { emailRegEx, usernameRegEx } from '@logto/core-kit';
22
import type { User } from '@logto/schemas';
33
import { parsePhoneNumber } from '@logto/shared/universal';
44
import { conditionalString, trySafe } from '@silverhand/essentials';
5-
import { parsePhoneNumberWithError } from 'libphonenumber-js';
5+
import { parsePhoneNumberWithError } from 'libphonenumber-js/mobile';
66
import { useForm, useController } from 'react-hook-form';
77
import { toast } from 'react-hot-toast';
88
import { Trans, useTranslation } from 'react-i18next';

packages/core/src/libraries/user.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import type { BindMfa, CreateUser, Scope, User } from '@logto/schemas';
2-
import { RoleType, Users, UsersPasswordEncryptionMethod } from '@logto/schemas';
3-
import { generateStandardId, generateStandardShortId } from '@logto/shared';
4-
import { condArray, deduplicateByKey, type Nullable } from '@silverhand/essentials';
2+
import { RoleType, UsersPasswordEncryptionMethod } from '@logto/schemas';
3+
import { generateStandardShortId, generateStandardId } from '@logto/shared';
4+
import type { Nullable } from '@silverhand/essentials';
5+
import { deduplicateByKey, condArray } from '@silverhand/essentials';
56
import { argon2Verify, bcryptVerify, md5, sha1, sha256 } from 'hash-wasm';
67
import pRetry from 'p-retry';
78

8-
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
99
import { EnvSet } from '#src/env-set/index.js';
1010
import RequestError from '#src/errors/RequestError/index.js';
1111
import { type JitOrganization } from '#src/queries/organization/email-domains.js';
@@ -33,6 +33,7 @@ export const createUserLibrary = (queries: Queries) => {
3333
hasUserWithIdentity,
3434
findUsersByIds,
3535
updateUserById,
36+
insertUser: insertUserQuery,
3637
findUserById,
3738
},
3839
usersRoles: { findUsersRolesByRoleId, findUsersRolesByUserId },
@@ -70,10 +71,6 @@ export const createUserLibrary = (queries: Queries) => {
7071
assertThat(parameterRoles.length === roleNames.length, 'role.default_role_missing');
7172

7273
return pool.transaction(async (connection) => {
73-
const insertUserQuery = buildInsertIntoWithPool(connection)(Users, {
74-
returning: true,
75-
});
76-
7774
const user = await insertUserQuery(data);
7875
const roles = deduplicateByKey([...parameterRoles, ...defaultRoles], 'id');
7976

packages/core/src/queries/user.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { User, CreateUser } from '@logto/schemas';
22
import { Users } from '@logto/schemas';
33
import { PhoneNumberParser } from '@logto/shared';
4-
import { conditionalArray, type Nullable, pick } from '@silverhand/essentials';
4+
import { cond, conditionalArray, type Nullable, pick } from '@silverhand/essentials';
55
import type { CommonQueryMethods } from '@silverhand/slonik';
66
import { sql } from '@silverhand/slonik';
77

@@ -12,6 +12,9 @@ import type { Search } from '#src/utils/search.js';
1212
import { buildConditionsFromSearch } from '#src/utils/search.js';
1313
import type { OmitAutoSetFields } from '#src/utils/sql.js';
1414
import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js';
15+
import { validatePhoneNumber } from '#src/utils/user.js';
16+
17+
import { buildInsertIntoWithPool } from '../database/insert-into.js';
1518

1619
const { table, fields } = convertToIdentifiers(Users);
1720

@@ -337,7 +340,43 @@ export const createUserQueries = (pool: CommonQueryMethods) => {
337340
id: string,
338341
set: Partial<OmitAutoSetFields<CreateUser>>,
339342
jsonbMode: 'replace' | 'merge' = 'merge'
340-
) => updateUser({ set, where: { id }, jsonbMode });
343+
) => {
344+
if (set.primaryPhone) {
345+
validatePhoneNumber(set.primaryPhone);
346+
}
347+
348+
return updateUser({
349+
set: {
350+
...set,
351+
...cond(
352+
set.primaryPhone && {
353+
primaryPhone: new PhoneNumberParser(set.primaryPhone).internationalNumber,
354+
}
355+
),
356+
},
357+
where: { id },
358+
jsonbMode,
359+
});
360+
};
361+
362+
const insertUserQuery = buildInsertIntoWithPool(pool)(Users, {
363+
returning: true,
364+
});
365+
366+
const insertUser = async (data: OmitAutoSetFields<CreateUser>) => {
367+
if (data.primaryPhone) {
368+
validatePhoneNumber(data.primaryPhone);
369+
}
370+
371+
return insertUserQuery({
372+
...data,
373+
...cond(
374+
data.primaryPhone && {
375+
primaryPhone: new PhoneNumberParser(data.primaryPhone).internationalNumber,
376+
}
377+
),
378+
});
379+
};
341380

342381
const deleteUserById = async (id: string) => {
343382
const { rowCount } = await pool.query(sql`
@@ -395,6 +434,7 @@ export const createUserQueries = (pool: CommonQueryMethods) => {
395434
findUsers,
396435
findUsersByIds,
397436
updateUserById,
437+
insertUser,
398438
deleteUserById,
399439
deleteUserIdentity,
400440
hasActiveUsers,

packages/core/src/routes/admin-user/mfa-verifications.test.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,20 @@ await mockEsmWithActual('../interaction/utils/backup-code-validation.js', () =>
3838
generateBackupCodes: jest.fn().mockReturnValue(['code']),
3939
}));
4040

41-
const usersLibraries = {
42-
generateUserId: jest.fn(async () => 'fooId'),
43-
insertUser: jest.fn(
44-
async (user: CreateUser): Promise<InsertUserResult> => [
45-
{
46-
...mockUser,
47-
...removeUndefinedKeys(user), // No undefined values will be returned from database
48-
},
49-
]
50-
),
51-
} satisfies Partial<Libraries['users']>;
41+
const mockLibraries = {
42+
users: {
43+
generateUserId: jest.fn(async () => 'fooId'),
44+
insertUser: jest.fn(
45+
async (user: CreateUser): Promise<InsertUserResult> => [
46+
{
47+
...mockUser,
48+
...removeUndefinedKeys(user), // No undefined values will be returned from database
49+
},
50+
]
51+
),
52+
addUserMfaVerification: jest.fn(),
53+
},
54+
} satisfies Partial2<Libraries>;
5255

5356
const codes = [
5457
'd94c2f29ae',
@@ -66,9 +69,7 @@ const codes = [
6669
const adminUserRoutes = await pickDefault(import('./mfa-verifications.js'));
6770

6871
describe('adminUserRoutes', () => {
69-
const tenantContext = new MockTenant(undefined, mockedQueries, undefined, {
70-
users: usersLibraries,
71-
});
72+
const tenantContext = new MockTenant(undefined, mockedQueries, undefined, mockLibraries);
7273
const userRequest = createRequester({ authedRoutes: adminUserRoutes, tenantContext });
7374

7475
afterEach(() => {

packages/core/src/routes/interaction/utils/single-sign-on.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
1818
import SamlConnector from '#src/sso/SamlConnector/index.js';
1919
import { ssoConnectorFactories, type SingleSignOnConnectorSession } from '#src/sso/index.js';
2020
import { type ExtendedSocialUserInfo } from '#src/sso/types/saml.js';
21-
import type Queries from '#src/tenants/Queries.js';
2221
import type TenantContext from '#src/tenants/TenantContext.js';
2322
import assertThat from '#src/utils/assert-that.js';
2423
import { safeParseUnknownJson } from '#src/utils/json.js';
@@ -223,7 +222,7 @@ export const handleSsoAuthentication = async (
223222

224223
// SignIn
225224
if (userSsoIdentity) {
226-
return signInWithSsoAuthentication(ctx, queries, {
225+
return signInWithSsoAuthentication(ctx, tenant, {
227226
connectorData,
228227
userSsoIdentity,
229228
ssoAuthentication,
@@ -252,7 +251,7 @@ export const handleSsoAuthentication = async (
252251

253252
const signInWithSsoAuthentication = async (
254253
ctx: WithInteractionHooksContext<WithLogContext>,
255-
{ userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries }: Queries,
254+
{ queries: { userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries } }: TenantContext,
256255
{
257256
connectorData: { id: connectorId, syncProfile },
258257
userSsoIdentity: { id, userId },

packages/core/src/utils/user.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import {
66
type User,
77
type UserMfaVerificationResponse,
88
} from '@logto/schemas';
9+
import { PhoneNumberParser } from '@logto/shared/universal';
910
import { pick } from '@silverhand/essentials';
1011

12+
import RequestError from '#src/errors/RequestError/index.js';
13+
1114
export const transpileUserMfaVerifications = (
1215
mfaVerifications: User['mfaVerifications']
1316
): UserMfaVerificationResponse => {
@@ -62,3 +65,20 @@ export const transpileUserProfileResponse = (
6265
...(ssoIdentities && { ssoIdentities }),
6366
};
6467
};
68+
69+
// Not used yet, may be used in the future, keep as a individual method.
70+
const getValidPhoneNumber = (phone: string): string => {
71+
if (!phone) {
72+
return phone;
73+
}
74+
75+
try {
76+
return PhoneNumberParser.parse(phone).number;
77+
} catch (error) {
78+
throw new RequestError({ code: 'user.invalid_phone', status: 422 }, error);
79+
}
80+
};
81+
82+
export const validatePhoneNumber = (phone: string): void => {
83+
getValidPhoneNumber(phone);
84+
};

packages/experience/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@logto/phrases": "workspace:^1.19.0",
2828
"@logto/phrases-experience": "workspace:^1.10.0",
2929
"@logto/schemas": "workspace:^1.27.0",
30+
"@logto/shared": "workspace:^3.1.4",
3031
"@react-spring/shared": "^9.6.1",
3132
"@react-spring/web": "^9.6.1",
3233
"@silverhand/eslint-config": "6.0.1",
@@ -64,7 +65,7 @@
6465
"jest-transformer-svg": "^2.0.0",
6566
"js-base64": "^3.7.5",
6667
"ky": "^1.2.3",
67-
"libphonenumber-js": "^1.10.51",
68+
"libphonenumber-js": "^1.12.6",
6869
"lint-staged": "^15.0.0",
6970
"postcss": "^8.4.31",
7071
"postcss-modules": "^6.0.0",

packages/experience/src/utils/country-code.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1+
import { PhoneNumberParser } from '@logto/shared/universal';
12
import i18next from 'i18next';
23
import type { CountryCode, CountryCallingCode } from 'libphonenumber-js/mobile';
3-
import {
4-
getCountries,
5-
getCountryCallingCode,
6-
parsePhoneNumberWithError,
7-
} from 'libphonenumber-js/mobile';
4+
import { getCountries, getCountryCallingCode } from 'libphonenumber-js/mobile';
85

96
export const fallbackCountryCode = 'US';
107

@@ -86,17 +83,9 @@ export const getCountryList = (): CountryMetaData[] => {
8683
];
8784
};
8885

89-
export const parseE164Number = (value: string) => {
90-
if (!value || value.startsWith('+')) {
91-
return value;
92-
}
93-
94-
return `+${value}`;
95-
};
96-
9786
export const formatPhoneNumberWithCountryCallingCode = (number: string) => {
9887
try {
99-
const phoneNumber = parsePhoneNumberWithError(parseE164Number(number));
88+
const phoneNumber = PhoneNumberParser.parse(number);
10089

10190
return `+${phoneNumber.countryCallingCode} ${phoneNumber.nationalNumber}`;
10291
} catch {
@@ -106,7 +95,7 @@ export const formatPhoneNumberWithCountryCallingCode = (number: string) => {
10695

10796
export const parsePhoneNumber = (value: string) => {
10897
try {
109-
const phoneNumber = parsePhoneNumberWithError(parseE164Number(value));
98+
const phoneNumber = PhoneNumberParser.parse(value);
11099

111100
return {
112101
countryCallingCode: phoneNumber.countryCallingCode,

packages/experience/src/utils/form.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { usernameRegEx, emailRegEx } from '@logto/core-kit';
22
import { SignInIdentifier } from '@logto/schemas';
3+
import { PhoneNumberParser } from '@logto/shared/universal';
34
import i18next from 'i18next';
45
import type { TFuncKey } from 'i18next';
5-
import { parsePhoneNumberWithError, ParseError } from 'libphonenumber-js/mobile';
6+
import { ParseError } from 'libphonenumber-js/mobile';
67

78
import type { ErrorType } from '@/components/ErrorMessage';
89
import type { IdentifierInputType } from '@/components/InputFields/SmartInputField';
9-
import { parseE164Number, parsePhoneNumber } from '@/utils/country-code';
10+
import { parsePhoneNumber } from '@/utils/country-code';
1011

1112
const { t } = i18next;
1213

@@ -32,7 +33,7 @@ export const validateEmail = (email: string): ErrorType | undefined => {
3233

3334
export const validatePhone = (value: string): ErrorType | undefined => {
3435
try {
35-
const phoneNumber = parsePhoneNumberWithError(parseE164Number(value));
36+
const phoneNumber = PhoneNumberParser.parse(value);
3637

3738
if (!phoneNumber.isValid()) {
3839
return 'invalid_phone';

0 commit comments

Comments
 (0)