Skip to content

Commit 585f09f

Browse files
authored
fix(account-center,schemas): validate email/phone format before sending verification code (#8082)
fix(account-center): validate email format before sending verification code Add client-side and server-side validation for email/phone format in the account-center email linking flow. Previously, entering an invalid email like "foo" would result in "internal server error" instead of a proper validation message. Changes: - Add client-side email validation using emailRegEx before sending - Add ErrorMessage component to display validation errors - Update verificationCodeIdentifierGuard to validate email/phone format - Add account-center README with import guidelines
1 parent d583051 commit 585f09f

File tree

5 files changed

+89
-10
lines changed

5 files changed

+89
-10
lines changed

packages/account-center/README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Account Center
2+
3+
The Logto account center app that allows users to manage their account settings, profile, and security options.
4+
5+
## Development
6+
7+
```bash
8+
pnpm dev
9+
```
10+
11+
## Import Guidelines
12+
13+
This package uses path aliases for cleaner imports:
14+
15+
- `@ac/*` - Account center source files (`./src/*`)
16+
- `@experience/*` - Experience package shared files
17+
18+
### Important: Experience Package Imports
19+
20+
When importing from the experience package, **only import from the `shared` folder**:
21+
22+
```typescript
23+
// ✅ Correct - importing from shared folder
24+
import Button from '@experience/shared/components/Button';
25+
import SmartInputField from '@experience/shared/components/InputFields/SmartInputField';
26+
27+
// ❌ Incorrect - importing from non-shared folders
28+
import { validateIdentifierField } from '@experience/utils/form';
29+
import useSendVerificationCode from '@experience/hooks/use-send-verification-code';
30+
```
31+
32+
### Why This Restriction?
33+
34+
1. **Shared folder** (`@experience/shared/*`) contains components and utilities designed to be reused across packages
35+
2. **Non-shared folders** may have dependencies, hooks, or context providers that are specific to the experience package's internal architecture
36+
3. Importing non-shared code can cause:
37+
- Missing context providers
38+
- Circular dependencies
39+
- Build/runtime errors
40+
41+
### What To Do Instead
42+
43+
If you need functionality from `@experience/utils/*` or other non-shared locations:
44+
45+
1. **Use shared packages** - Check if `@logto/core-kit`, `@logto/schemas`, or `@logto/shared` has what you need
46+
2. **Re-implement locally** - Simple utilities can be implemented in account-center
47+
3. **Move to shared** - If the utility is truly reusable, consider moving it to `@experience/shared/`
48+
49+
### Available Shared Packages
50+
51+
- `@logto/core-kit` - Core utilities like `emailRegEx` for validation
52+
- `@logto/schemas` - TypeScript types and schemas
53+
- `@logto/phrases-experience` - i18n translations
54+
- `@logto/shared/universal` - Universal utilities (browser + Node.js compatible)

packages/account-center/src/pages/CodeFlow/IdentifierSendStep.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import Button from '@experience/shared/components/Button';
2+
import ErrorMessage from '@experience/shared/components/ErrorMessage';
23
import SmartInputField from '@experience/shared/components/InputFields/SmartInputField';
3-
import { type SignInIdentifier } from '@logto/schemas';
4+
import { emailRegEx } from '@logto/core-kit';
5+
import { SignInIdentifier, type SignInIdentifier as SignInIdentifierType } from '@logto/schemas';
46
import { type TFuncKey } from 'i18next';
57
import { useContext, useEffect, useState, type FormEvent } from 'react';
68
import { useTranslation } from 'react-i18next';
@@ -18,7 +20,7 @@ export type IdentifierLabelKey =
1820
| 'account_center.phone_verification.phone_label';
1921

2022
type Props = {
21-
readonly identifierType: SignInIdentifier.Email | SignInIdentifier.Phone;
23+
readonly identifierType: SignInIdentifierType.Email | SignInIdentifierType.Phone;
2224
readonly name: string;
2325
readonly labelKey: IdentifierLabelKey;
2426
readonly titleKey: TFuncKey;
@@ -48,6 +50,7 @@ const IdentifierSendStep = ({
4850
const { loading } = useContext(LoadingContext);
4951
const { setToast } = useContext(PageContext);
5052
const [pendingValue, setPendingValue] = useState(value);
53+
const [errorMessage, setErrorMessage] = useState<string>();
5154
const sendCodeRequest = useApi(sendCode);
5255
const handleError = useErrorHandler();
5356

@@ -63,6 +66,15 @@ const IdentifierSendStep = ({
6366
return;
6467
}
6568

69+
// Validate email format before sending
70+
if (identifierType === SignInIdentifier.Email && !emailRegEx.test(target)) {
71+
setErrorMessage(t('error.invalid_email'));
72+
return;
73+
}
74+
75+
// Clear any previous validation error
76+
setErrorMessage(undefined);
77+
6678
const [error, result] = await sendCodeRequest(target);
6779

6880
if (error) {
@@ -87,12 +99,16 @@ const IdentifierSendStep = ({
8799
label={t(labelKey)}
88100
defaultValue={pendingValue}
89101
enabledTypes={[identifierType]}
102+
isDanger={Boolean(errorMessage)}
90103
onChange={(inputValue) => {
91-
if (inputValue.type === identifierType) {
104+
if (inputValue.type === identifierType && inputValue.value !== pendingValue) {
92105
setPendingValue(inputValue.value);
106+
// Clear error when user modifies input
107+
setErrorMessage(undefined);
93108
}
94109
}}
95110
/>
111+
{errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>}
96112
<Button
97113
title="account_center.code_verification.send"
98114
type="primary"

packages/integration-tests/src/tests/api/experience-api/verifications/social-verification.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ describe('social verification', () => {
134134
const { verificationId } = await client.sendVerificationCode({
135135
identifier: {
136136
type: SignInIdentifier.Email,
137-
value: 'foo',
137+
value: 'foo@logto.io',
138138
},
139139
interactionEvent: InteractionEvent.SignIn,
140140
});

packages/integration-tests/src/tests/api/experience-api/verifications/verification-code.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe('Verification code verification APIs', () => {
3333
},
3434
{
3535
type: SignInIdentifier.Phone,
36-
value: '+1234567890',
36+
value: '1234567890',
3737
},
3838
];
3939

@@ -144,12 +144,15 @@ describe('Verification code verification APIs', () => {
144144
},
145145
});
146146

147+
// Use a valid but different identifier to trigger the mismatch error
148+
const differentValue = type === SignInIdentifier.Email ? '[email protected]' : '9876543210';
149+
147150
await expectRejects(
148151
client.verifyVerificationCode({
149152
code,
150153
identifier: {
151154
type,
152-
value: 'invalid_identifier',
155+
value: differentValue,
153156
},
154157
verificationId,
155158
}),

packages/schemas/src/types/interactions.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,16 @@ export type VerificationCodeIdentifier<
6262
type: T;
6363
value: string;
6464
};
65-
export const verificationCodeIdentifierGuard = z.object({
66-
type: z.enum([SignInIdentifier.Email, SignInIdentifier.Phone]),
67-
value: z.string(),
68-
}) satisfies ToZodObject<VerificationCodeIdentifier>;
65+
export const verificationCodeIdentifierGuard = z.discriminatedUnion('type', [
66+
z.object({
67+
type: z.literal(SignInIdentifier.Email),
68+
value: z.string().regex(emailRegEx),
69+
}),
70+
z.object({
71+
type: z.literal(SignInIdentifier.Phone),
72+
value: z.string().regex(phoneRegEx),
73+
}),
74+
]) satisfies z.ZodType<VerificationCodeIdentifier>;
6975

7076
// REMARK: API payload guard
7177

0 commit comments

Comments
 (0)