Skip to content

Commit 39293e3

Browse files
authored
feat(account-center): link phone and email (#8019)
* feat(account-center): link new email * feat(account-center): update success page * fix(account-center): handle existing email * feat(account-center): link phone * refactor(account-center): simplify phrases * chore: update translations
1 parent 22ac57b commit 39293e3

30 files changed

+1388
-135
lines changed

packages/account-center/src/App.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,18 @@ import PageContextProvider from './Providers/PageContextProvider';
1212
import PageContext from './Providers/PageContextProvider/PageContext';
1313
import BrandingHeader from './components/BrandingHeader';
1414
import ErrorPage from './components/ErrorPage';
15-
import { emailRoute, sessionExpiredRoute } from './constants/routes';
15+
import {
16+
emailRoute,
17+
phoneRoute,
18+
sessionExpiredRoute,
19+
updateSuccessRoute,
20+
} from './constants/routes';
1621
import initI18n from './i18n/init';
1722
import Email from './pages/Email';
1823
import Home from './pages/Home';
24+
import Phone from './pages/Phone';
1925
import SessionExpired from './pages/SessionExpired';
26+
import UpdateSuccess from './pages/UpdateSuccess';
2027
import { accountCenterBasePath, handleAccountCenterRoute } from './utils/account-center-route';
2128

2229
import '@experience/shared/scss/normalized.scss';
@@ -67,7 +74,9 @@ const Main = () => {
6774
return (
6875
<Routes>
6976
<Route path={sessionExpiredRoute} element={<SessionExpired />} />
77+
<Route path={updateSuccessRoute} element={<UpdateSuccess />} />
7078
<Route path={emailRoute} element={<Email />} />
79+
<Route path={phoneRoute} element={<Phone />} />
7180
<Route index element={<Home />} />
7281
<Route path="*" element={<Home />} />
7382
</Routes>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { createAuthenticatedKy } from './base-ky';
2+
3+
export const verificationRecordIdHeader = 'logto-verification-id';
4+
5+
export const updatePrimaryEmail = async (
6+
accessToken: string,
7+
verificationRecordId: string,
8+
payload: { email: string; newIdentifierVerificationRecordId: string }
9+
) => {
10+
await createAuthenticatedKy(accessToken).post('/api/my-account/primary-email', {
11+
json: payload,
12+
headers: { [verificationRecordIdHeader]: verificationRecordId },
13+
});
14+
};
15+
16+
export const updatePrimaryPhone = async (
17+
accessToken: string,
18+
verificationRecordId: string,
19+
payload: { phone: string; newIdentifierVerificationRecordId: string }
20+
) => {
21+
await createAuthenticatedKy(accessToken).post('/api/my-account/primary-phone', {
22+
json: payload,
23+
headers: { [verificationRecordIdHeader]: verificationRecordId },
24+
});
25+
};
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export const sessionExpiredRoute = '/session-expired';
22
export const emailRoute = '/email';
3+
export const phoneRoute = '/phone';
4+
export const updateSuccessRoute = '/update-success';
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import { useLogto } from '@logto/react';
2+
import type { AccountCenter, SignInIdentifier } from '@logto/schemas';
3+
import { AccountCenterControlValue } from '@logto/schemas';
4+
import { type TFuncKey } from 'i18next';
5+
import { useCallback, useContext, useMemo, useState } from 'react';
6+
import { useTranslation } from 'react-i18next';
7+
import { useNavigate } from 'react-router-dom';
8+
9+
import LoadingContext from '@ac/Providers/LoadingContextProvider/LoadingContext';
10+
import PageContext from '@ac/Providers/PageContextProvider/PageContext';
11+
import ErrorPage from '@ac/components/ErrorPage';
12+
import VerificationMethodList from '@ac/components/VerificationMethodList';
13+
import { updateSuccessRoute } from '@ac/constants/routes';
14+
import useApi from '@ac/hooks/use-api';
15+
import useErrorHandler from '@ac/hooks/use-error-handler';
16+
17+
import IdentifierSendStep, { type IdentifierLabelKey } from './IdentifierSendStep';
18+
import IdentifierVerifyStep from './IdentifierVerifyStep';
19+
20+
type AccountCenterField = keyof AccountCenter['fields'];
21+
22+
type IdentifierBindingPageProps<VerifyPayload, BindPayload> = {
23+
readonly identifierType: SignInIdentifier.Email | SignInIdentifier.Phone;
24+
readonly accountField: AccountCenterField;
25+
readonly sendStep: {
26+
titleKey: TFuncKey;
27+
descriptionKey: TFuncKey;
28+
inputLabelKey: IdentifierLabelKey;
29+
inputName: string;
30+
};
31+
readonly verifyStep: {
32+
titleKey: TFuncKey;
33+
descriptionKey: TFuncKey;
34+
descriptionPropsBuilder: (identifier: string) => Record<string, string>;
35+
codeInputName: string;
36+
};
37+
readonly mismatchErrorCode: string;
38+
readonly sendCode: (
39+
accessToken: string,
40+
identifier: string
41+
) => Promise<{
42+
verificationRecordId: string;
43+
expiresAt: string;
44+
}>;
45+
readonly verifyCode: (
46+
accessToken: string,
47+
payload: VerifyPayload
48+
) => Promise<{
49+
verificationRecordId: string;
50+
}>;
51+
readonly bindIdentifier: (
52+
accessToken: string,
53+
verificationRecordId: string,
54+
payload: BindPayload
55+
) => Promise<unknown>;
56+
readonly buildVerifyPayload: (
57+
identifier: string,
58+
verificationRecordId: string,
59+
code: string
60+
) => VerifyPayload;
61+
readonly buildBindPayload: (identifier: string, verificationRecordId: string) => BindPayload;
62+
readonly successRedirect?: string;
63+
readonly initialValue?: string;
64+
};
65+
66+
const IdentifierBindingPage = <VerifyPayload, BindPayload>({
67+
identifierType,
68+
accountField,
69+
sendStep,
70+
verifyStep,
71+
mismatchErrorCode,
72+
sendCode,
73+
verifyCode,
74+
bindIdentifier,
75+
buildVerifyPayload,
76+
buildBindPayload,
77+
successRedirect = updateSuccessRoute,
78+
initialValue = '',
79+
}: IdentifierBindingPageProps<VerifyPayload, BindPayload>) => {
80+
const { t } = useTranslation();
81+
const navigate = useNavigate();
82+
const { getAccessToken } = useLogto();
83+
const { loading } = useContext(LoadingContext);
84+
const { accountCenterSettings, verificationId, setToast, setVerificationId } =
85+
useContext(PageContext);
86+
const [identifier, setIdentifier] = useState(initialValue);
87+
const [pendingIdentifier, setPendingIdentifier] = useState<string>();
88+
const [pendingVerificationRecordId, setPendingVerificationRecordId] = useState<string>();
89+
const [verifyResetSignal, setVerifyResetSignal] = useState(0);
90+
const verifyCodeRequest = useApi(verifyCode);
91+
const bindIdentifierRequest = useApi(bindIdentifier);
92+
const handleError = useErrorHandler();
93+
94+
const resetFlow = useCallback((shouldClearIdentifier = false) => {
95+
setPendingIdentifier(undefined);
96+
setPendingVerificationRecordId(undefined);
97+
98+
if (shouldClearIdentifier) {
99+
setIdentifier('');
100+
}
101+
}, []);
102+
103+
const invalidCodeErrorCodes = useMemo(
104+
() => [
105+
'verification_code.not_found',
106+
mismatchErrorCode,
107+
'verification_code.code_mismatch',
108+
'verification_code.expired',
109+
'verification_code.exceed_max_try',
110+
],
111+
[mismatchErrorCode]
112+
);
113+
114+
const resetFlowErrorCodes = useMemo(() => ['verification_record.not_found'], []);
115+
116+
const handleVerifyError = useCallback(
117+
async (error: unknown) => {
118+
const invalidHandlers = Object.fromEntries(
119+
invalidCodeErrorCodes.map((code) => [
120+
code,
121+
async () => {
122+
setVerifyResetSignal((current) => current + 1);
123+
setToast(t('account_center.verification.error_invalid_code'));
124+
},
125+
])
126+
);
127+
128+
const resetHandlers = Object.fromEntries(
129+
resetFlowErrorCodes.map((code) => [
130+
code,
131+
async () => {
132+
resetFlow(true);
133+
setToast(t('account_center.verification.error_invalid_code'));
134+
},
135+
])
136+
);
137+
138+
await handleError(error ?? new Error(t('account_center.verification.error_verify_failed')), {
139+
...invalidHandlers,
140+
...resetHandlers,
141+
});
142+
},
143+
[handleError, invalidCodeErrorCodes, resetFlow, resetFlowErrorCodes, setToast, t]
144+
);
145+
146+
const handleVerifyAndBind = useCallback(
147+
async (code: string) => {
148+
if (!pendingIdentifier || !pendingVerificationRecordId || loading || !verificationId) {
149+
return;
150+
}
151+
152+
const accessToken = await getAccessToken();
153+
154+
if (!accessToken) {
155+
setToast(t('account_center.verification.error_verify_failed'));
156+
return;
157+
}
158+
159+
const [verifyError, verifyResult] = await verifyCodeRequest(
160+
accessToken,
161+
buildVerifyPayload(pendingIdentifier, pendingVerificationRecordId, code)
162+
);
163+
164+
if (verifyError || !verifyResult) {
165+
await handleVerifyError(verifyError);
166+
return;
167+
}
168+
169+
const [bindError] = await bindIdentifierRequest(
170+
accessToken,
171+
verificationId,
172+
buildBindPayload(pendingIdentifier, verifyResult.verificationRecordId)
173+
);
174+
175+
if (bindError) {
176+
await handleError(bindError, {
177+
'verification_record.permission_denied': async () => {
178+
setVerificationId(undefined);
179+
resetFlow(true);
180+
setToast(t('account_center.verification.verification_required'));
181+
},
182+
});
183+
return;
184+
}
185+
186+
void navigate(successRedirect, { replace: true });
187+
},
188+
[
189+
bindIdentifierRequest,
190+
buildBindPayload,
191+
buildVerifyPayload,
192+
getAccessToken,
193+
handleError,
194+
handleVerifyError,
195+
loading,
196+
navigate,
197+
pendingIdentifier,
198+
pendingVerificationRecordId,
199+
resetFlow,
200+
setToast,
201+
setVerificationId,
202+
successRedirect,
203+
t,
204+
verificationId,
205+
verifyCodeRequest,
206+
]
207+
);
208+
209+
if (
210+
!accountCenterSettings?.enabled ||
211+
accountCenterSettings.fields[accountField] !== AccountCenterControlValue.Edit
212+
) {
213+
return (
214+
<ErrorPage titleKey="error.something_went_wrong" messageKey="error.feature_not_enabled" />
215+
);
216+
}
217+
218+
if (!verificationId) {
219+
return <VerificationMethodList />;
220+
}
221+
222+
return !pendingIdentifier || !pendingVerificationRecordId ? (
223+
<IdentifierSendStep
224+
identifierType={identifierType}
225+
name={sendStep.inputName}
226+
labelKey={sendStep.inputLabelKey}
227+
titleKey={sendStep.titleKey}
228+
descriptionKey={sendStep.descriptionKey}
229+
value={identifier}
230+
sendCode={sendCode}
231+
onCodeSent={(value, recordId) => {
232+
setIdentifier(value);
233+
setPendingIdentifier(value);
234+
setPendingVerificationRecordId(recordId);
235+
}}
236+
/>
237+
) : (
238+
<IdentifierVerifyStep
239+
identifier={pendingIdentifier}
240+
verificationRecordId={pendingVerificationRecordId}
241+
codeInputName={verifyStep.codeInputName}
242+
translation={{
243+
titleKey: verifyStep.titleKey,
244+
descriptionKey: verifyStep.descriptionKey,
245+
descriptionProps: verifyStep.descriptionPropsBuilder(pendingIdentifier),
246+
}}
247+
sendCode={sendCode}
248+
resetSignal={verifyResetSignal}
249+
onResent={(recordId) => {
250+
setPendingVerificationRecordId(recordId);
251+
}}
252+
onSubmit={(value) => {
253+
void handleVerifyAndBind(value);
254+
}}
255+
onBack={() => {
256+
resetFlow(true);
257+
}}
258+
onInvalidCode={() => {
259+
setToast(t('account_center.verification.error_invalid_code'));
260+
}}
261+
/>
262+
);
263+
};
264+
265+
export default IdentifierBindingPage;

0 commit comments

Comments
 (0)