Skip to content

Commit 47a2eb7

Browse files
authored
refactor(account-center): use inline text field errors instead of toast (#8112)
Replace toast notifications with inline text field error messages for input validation errors in account-center, following the pattern from the experience package. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 70e14cf commit 47a2eb7

File tree

4 files changed

+130
-59
lines changed

4 files changed

+130
-59
lines changed

packages/account-center/src/components/SetPassword/index.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import ClearIcon from '@experience/shared/assets/icons/clear-icon.svg?react';
22
import Button from '@experience/shared/components/Button';
3-
import ErrorMessage from '@experience/shared/components/ErrorMessage';
43
import IconButton from '@experience/shared/components/IconButton';
54
import InputField from '@experience/shared/components/InputFields/InputField';
6-
import { useCallback, useMemo, useState, type FormEvent, type ReactNode } from 'react';
5+
import { useCallback, useState, type FormEvent, type ReactNode } from 'react';
76
import { useTranslation } from 'react-i18next';
87

98
import TogglePassword from './TogglePassword';
@@ -14,6 +13,7 @@ type Props = {
1413
// eslint-disable-next-line react/boolean-prop-naming
1514
readonly autoFocus?: boolean;
1615
readonly onSubmit: (password: string) => Promise<void>;
16+
/** Error message for the password field (e.g. password policy errors) */
1717
readonly errorMessage?: string;
1818
readonly clearErrorMessage?: () => void;
1919
readonly maxLength?: number;
@@ -32,24 +32,22 @@ const SetPassword = ({
3232
const { t } = useTranslation();
3333
const [newPassword, setNewPassword] = useState('');
3434
const [confirmPassword, setConfirmPassword] = useState('');
35-
const [localError, setLocalError] = useState<string>();
35+
const [confirmPasswordError, setConfirmPasswordError] = useState<string>();
3636
const [isSubmitting, setIsSubmitting] = useState(false);
3737
const [showPassword, setShowPassword] = useState(false);
3838

39-
const mergedError = useMemo(() => localError ?? errorMessage, [errorMessage, localError]);
40-
4139
const handleSubmit = useCallback(
4240
async (event?: FormEvent<HTMLFormElement>) => {
4341
event?.preventDefault();
44-
setLocalError(undefined);
42+
setConfirmPasswordError(undefined);
4543
clearErrorMessage?.();
4644

4745
if (!newPassword || !confirmPassword) {
4846
return;
4947
}
5048

5149
if (confirmPassword !== newPassword) {
52-
setLocalError(t('error.passwords_do_not_match'));
50+
setConfirmPasswordError(t('error.passwords_do_not_match'));
5351
return;
5452
}
5553

@@ -76,6 +74,8 @@ const SetPassword = ({
7674
autoFocus={autoFocus}
7775
value={newPassword}
7876
maxLength={maxLength}
77+
isDanger={Boolean(errorMessage)}
78+
errorMessage={errorMessage}
7979
isSuffixFocusVisible={Boolean(newPassword)}
8080
suffix={
8181
<IconButton
@@ -100,6 +100,8 @@ const SetPassword = ({
100100
label={t('input.confirm_password')}
101101
value={confirmPassword}
102102
maxLength={maxLength}
103+
isDanger={Boolean(confirmPasswordError)}
104+
errorMessage={confirmPasswordError}
103105
isSuffixFocusVisible={Boolean(confirmPassword)}
104106
suffix={
105107
<IconButton
@@ -117,8 +119,6 @@ const SetPassword = ({
117119
}}
118120
/>
119121

120-
{mergedError && <ErrorMessage className={styles.formErrors}>{mergedError}</ErrorMessage>}
121-
122122
<TogglePassword isChecked={showPassword} onChange={setShowPassword} />
123123

124124
<Button

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

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ import useErrorHandler from '@ac/hooks/use-error-handler';
1515
import IdentifierSendStep, { type IdentifierLabelKey } from './IdentifierSendStep';
1616
import IdentifierVerifyStep from './IdentifierVerifyStep';
1717

18+
type ErrorState = {
19+
sendError?: string;
20+
verifyError?: string;
21+
};
22+
1823
type AccountCenterField = keyof AccountCenter['fields'];
1924

2025
type IdentifierBindingPageProps<VerifyPayload, BindPayload> = {
@@ -84,13 +89,23 @@ const IdentifierBindingPage = <VerifyPayload, BindPayload>({
8489
const [pendingIdentifier, setPendingIdentifier] = useState<string>();
8590
const [pendingVerificationRecordId, setPendingVerificationRecordId] = useState<string>();
8691
const [verifyResetSignal, setVerifyResetSignal] = useState(0);
92+
const [errorState, setErrorState] = useState<ErrorState>({});
8793
const verifyCodeRequest = useApi(verifyCode);
8894
const bindIdentifierRequest = useApi(bindIdentifier);
8995
const handleError = useErrorHandler();
9096

97+
const clearSendError = useCallback(() => {
98+
setErrorState((current) => ({ ...current, sendError: undefined }));
99+
}, []);
100+
101+
const clearVerifyError = useCallback(() => {
102+
setErrorState((current) => ({ ...current, verifyError: undefined }));
103+
}, []);
104+
91105
const resetFlow = useCallback((shouldClearIdentifier = false) => {
92106
setPendingIdentifier(undefined);
93107
setPendingVerificationRecordId(undefined);
108+
setErrorState({});
94109

95110
if (shouldClearIdentifier) {
96111
setIdentifier('');
@@ -117,7 +132,10 @@ const IdentifierBindingPage = <VerifyPayload, BindPayload>({
117132
code,
118133
async () => {
119134
setVerifyResetSignal((current) => current + 1);
120-
setToast(t('account_center.verification.error_invalid_code'));
135+
setErrorState((current) => ({
136+
...current,
137+
verifyError: t('account_center.verification.error_invalid_code'),
138+
}));
121139
},
122140
])
123141
);
@@ -127,7 +145,10 @@ const IdentifierBindingPage = <VerifyPayload, BindPayload>({
127145
code,
128146
async () => {
129147
resetFlow(true);
130-
setToast(t('account_center.verification.error_invalid_code'));
148+
setErrorState((current) => ({
149+
...current,
150+
sendError: t('account_center.verification.error_invalid_code'),
151+
}));
131152
},
132153
])
133154
);
@@ -137,11 +158,13 @@ const IdentifierBindingPage = <VerifyPayload, BindPayload>({
137158
...resetHandlers,
138159
});
139160
},
140-
[handleError, invalidCodeErrorCodes, resetFlow, resetFlowErrorCodes, setToast, t]
161+
[handleError, invalidCodeErrorCodes, resetFlow, resetFlowErrorCodes, t]
141162
);
142163

143164
const handleVerifyAndBind = useCallback(
144165
async (code: string) => {
166+
clearVerifyError();
167+
145168
if (!pendingIdentifier || !pendingVerificationRecordId || loading || !verificationId) {
146169
return;
147170
}
@@ -162,6 +185,7 @@ const IdentifierBindingPage = <VerifyPayload, BindPayload>({
162185

163186
if (bindError) {
164187
await handleError(bindError, {
188+
// This is a global error (session expired) that requires re-verification, so we use toast
165189
'verification_record.permission_denied': async () => {
166190
setVerificationId(undefined);
167191
resetFlow(true);
@@ -177,6 +201,7 @@ const IdentifierBindingPage = <VerifyPayload, BindPayload>({
177201
bindIdentifierRequest,
178202
buildBindPayload,
179203
buildVerifyPayload,
204+
clearVerifyError,
180205
handleError,
181206
handleVerifyError,
182207
loading,
@@ -214,12 +239,17 @@ const IdentifierBindingPage = <VerifyPayload, BindPayload>({
214239
titleKey={sendStep.titleKey}
215240
descriptionKey={sendStep.descriptionKey}
216241
value={identifier}
242+
errorMessage={errorState.sendError}
243+
clearErrorMessage={clearSendError}
217244
sendCode={sendCode}
218245
onCodeSent={(value, recordId) => {
219246
setIdentifier(value);
220247
setPendingIdentifier(value);
221248
setPendingVerificationRecordId(recordId);
222249
}}
250+
onSendFailed={(message) => {
251+
setErrorState((current) => ({ ...current, sendError: message }));
252+
}}
223253
/>
224254
) : (
225255
<IdentifierVerifyStep
@@ -231,19 +261,25 @@ const IdentifierBindingPage = <VerifyPayload, BindPayload>({
231261
descriptionKey: verifyStep.descriptionKey,
232262
descriptionProps: verifyStep.descriptionPropsBuilder(pendingIdentifier),
233263
}}
264+
errorMessage={errorState.verifyError}
265+
clearErrorMessage={clearVerifyError}
234266
sendCode={sendCode}
235267
resetSignal={verifyResetSignal}
236268
onResent={(recordId) => {
237269
setPendingVerificationRecordId(recordId);
270+
clearVerifyError();
271+
}}
272+
onResendFailed={(message) => {
273+
setErrorState((current) => ({ ...current, verifyError: message }));
238274
}}
239275
onSubmit={(value) => {
240276
void handleVerifyAndBind(value);
241277
}}
242278
onBack={() => {
243279
resetFlow(true);
244280
}}
245-
onInvalidCode={() => {
246-
setToast(t('account_center.verification.error_invalid_code'));
281+
onInvalidCode={(message) => {
282+
setErrorState((current) => ({ ...current, verifyError: message }));
247283
}}
248284
/>
249285
);

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

Lines changed: 63 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@ import SmartInputField from '@experience/shared/components/InputFields/SmartInpu
44
import { emailRegEx } from '@logto/core-kit';
55
import { SignInIdentifier, type SignInIdentifier as SignInIdentifierType } from '@logto/schemas';
66
import { type TFuncKey } from 'i18next';
7-
import { useContext, useEffect, useState, type FormEvent } from 'react';
7+
import { useCallback, useContext, useEffect, useState, type FormEvent } from 'react';
88
import { useTranslation } from 'react-i18next';
99

1010
import LoadingContext from '@ac/Providers/LoadingContextProvider/LoadingContext';
11-
import PageContext from '@ac/Providers/PageContextProvider/PageContext';
1211
import useApi from '@ac/hooks/use-api';
1312
import useErrorHandler from '@ac/hooks/use-error-handler';
1413
import SecondaryPageLayout from '@ac/layouts/SecondaryPageLayout';
@@ -26,7 +25,10 @@ type Props = {
2625
readonly titleKey: TFuncKey;
2726
readonly descriptionKey: TFuncKey;
2827
readonly value: string;
28+
readonly errorMessage?: string;
29+
readonly clearErrorMessage?: () => void;
2930
readonly onCodeSent: (identifier: string, verificationRecordId: string) => void;
31+
readonly onSendFailed: (errorMessage: string) => void;
3032
readonly sendCode: (
3133
accessToken: string,
3234
identifier: string
@@ -43,52 +45,75 @@ const IdentifierSendStep = ({
4345
titleKey,
4446
descriptionKey,
4547
value,
48+
errorMessage,
49+
clearErrorMessage,
4650
onCodeSent,
51+
onSendFailed,
4752
sendCode,
4853
}: Props) => {
4954
const { t } = useTranslation();
5055
const { loading } = useContext(LoadingContext);
51-
const { setToast } = useContext(PageContext);
5256
const [pendingValue, setPendingValue] = useState(value);
53-
const [errorMessage, setErrorMessage] = useState<string>();
57+
const [localErrorMessage, setLocalErrorMessage] = useState<string>();
5458
const sendCodeRequest = useApi(sendCode);
5559
const handleError = useErrorHandler();
5660

5761
useEffect(() => {
5862
setPendingValue(value);
5963
}, [value]);
6064

61-
const handleSend = async (event?: FormEvent<HTMLFormElement>) => {
62-
event?.preventDefault();
63-
const target = pendingValue.trim();
64-
65-
if (!target || loading) {
66-
return;
67-
}
68-
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-
78-
const [error, result] = await sendCodeRequest(target);
79-
80-
if (error) {
81-
await handleError(error);
82-
return;
83-
}
84-
85-
if (!result) {
86-
setToast(t('account_center.verification.error_send_failed'));
87-
return;
88-
}
65+
const handleSend = useCallback(
66+
async (event?: FormEvent<HTMLFormElement>) => {
67+
event?.preventDefault();
68+
clearErrorMessage?.();
69+
70+
const target = pendingValue.trim();
71+
72+
if (!target || loading) {
73+
return;
74+
}
75+
76+
// Validate email format before sending
77+
if (identifierType === SignInIdentifier.Email && !emailRegEx.test(target)) {
78+
setLocalErrorMessage(t('error.invalid_email'));
79+
return;
80+
}
81+
82+
// Clear any previous validation error
83+
setLocalErrorMessage(undefined);
84+
85+
const [error, result] = await sendCodeRequest(target);
86+
87+
if (error) {
88+
await handleError(error, {
89+
'guard.invalid_input': async (requestError) => {
90+
onSendFailed(requestError.message);
91+
},
92+
});
93+
return;
94+
}
95+
96+
if (!result) {
97+
onSendFailed(t('account_center.verification.error_send_failed'));
98+
return;
99+
}
100+
101+
onCodeSent(target, result.verificationRecordId);
102+
},
103+
[
104+
clearErrorMessage,
105+
handleError,
106+
identifierType,
107+
loading,
108+
onCodeSent,
109+
onSendFailed,
110+
pendingValue,
111+
sendCodeRequest,
112+
t,
113+
]
114+
);
89115

90-
onCodeSent(target, result.verificationRecordId);
91-
};
116+
const displayError = errorMessage ?? localErrorMessage;
92117

93118
return (
94119
<SecondaryPageLayout title={titleKey} description={descriptionKey}>
@@ -101,16 +126,17 @@ const IdentifierSendStep = ({
101126
label={t(labelKey)}
102127
defaultValue={pendingValue}
103128
enabledTypes={[identifierType]}
104-
isDanger={Boolean(errorMessage)}
129+
isDanger={Boolean(displayError)}
130+
errorMessage={displayError}
105131
onChange={(inputValue) => {
106132
if (inputValue.type === identifierType && inputValue.value !== pendingValue) {
107133
setPendingValue(inputValue.value);
108134
// Clear error when user modifies input
109-
setErrorMessage(undefined);
135+
setLocalErrorMessage(undefined);
110136
}
111137
}}
112138
/>
113-
{errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>}
139+
{displayError && <ErrorMessage>{displayError}</ErrorMessage>}
114140
<Button
115141
title="account_center.code_verification.send"
116142
type="primary"

0 commit comments

Comments
 (0)