Skip to content

Commit 147f257

Browse files
authored
fix(experience,phrases): fix terms agreement modal behaviors on using magic link (#7839)
* fix(experience,phrases): fix terms agreement modal behaviors on using magic link * chore: add changeset
1 parent 3a9c1cf commit 147f257

File tree

28 files changed

+229
-65
lines changed

28 files changed

+229
-65
lines changed

.changeset/pink-trees-lay.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@logto/phrases-experience": patch
3+
"@logto/experience": patch
4+
---
5+
6+
fix a bug that prevents terms agreement dialog from working properly when using magic link authentication

packages/experience/src/App.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import PhoneVerificationCode from './pages/MfaVerification/PhoneVerificationCode
3030
import TotpVerification from './pages/MfaVerification/TotpVerification';
3131
import WebAuthnVerification from './pages/MfaVerification/WebAuthnVerification';
3232
import OneTimeToken from './pages/OneTimeToken';
33+
import OneTimeTokenErrorPage from './pages/OneTimeToken/Error';
3334
import Register from './pages/Register';
3435
import RegisterPassword from './pages/RegisterPassword';
3536
import ResetPassword from './pages/ResetPassword';
@@ -71,8 +72,12 @@ const App = () => {
7172
element={<SocialSignInWebCallback />}
7273
/>
7374
<Route path="direct/:method/:target?" element={<DirectSignIn />} />
75+
<Route path={experience.routes.oneTimeToken} element={<OneTimeToken />} />
7476
<Route element={<AppLayout />}>
75-
<Route path={experience.routes.oneTimeToken} element={<OneTimeToken />} />
77+
<Route
78+
path={`${experience.routes.oneTimeToken}/error`}
79+
element={<OneTimeTokenErrorPage />}
80+
/>
7681
<Route path={experience.routes.switchAccount} element={<SwitchAccount />} />
7782
<Route
7883
path="unknown-session"

packages/experience/src/apis/experience/one-time-token.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import api from '../api';
55
import { experienceApiRoutes, type VerificationResponse } from './const';
66
import { initInteraction } from './interaction';
77

8-
export const registerWithOneTimeToken = async (payload: OneTimeTokenVerificationVerifyPayload) => {
9-
await initInteraction(InteractionEvent.Register);
8+
export const signInWithOneTimeToken = async (payload: OneTimeTokenVerificationVerifyPayload) => {
9+
await initInteraction(InteractionEvent.SignIn);
1010

1111
return api
1212
.post(`${experienceApiRoutes.verification}/one-time-token/verify`, {

packages/experience/src/components/ConfirmModal/AcModal.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const AcModal = ({
2323
confirmText = 'action.confirm',
2424
confirmTextI18nProps,
2525
cancelTextI18nProps,
26+
shouldCloseOnOverlayClick = true,
27+
shouldCloseOnEsc = true,
2628
onConfirm,
2729
onClose,
2830
}: ModalProps) => {
@@ -32,6 +34,9 @@ const AcModal = ({
3234

3335
return (
3436
<ReactModal
37+
shouldCloseOnEsc={shouldCloseOnEsc}
38+
shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
39+
role="dialog"
3540
isOpen={isOpen}
3641
className={classNames(styles.modal, className)}
3742
overlayClassName={classNames(modalStyles.overlay, styles.overlay)}

packages/experience/src/components/ConfirmModal/MobileModal.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@ const MobileModal = ({
1818
confirmText = 'action.confirm',
1919
cancelTextI18nProps,
2020
confirmTextI18nProps,
21+
shouldCloseOnEsc = true,
22+
shouldCloseOnOverlayClick = true,
2123
onConfirm,
2224
onClose,
2325
}: ModalProps) => {
2426
return (
2527
<ReactModal
26-
shouldCloseOnEsc
28+
shouldCloseOnEsc={shouldCloseOnEsc}
29+
shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
2730
role="dialog"
2831
isOpen={isOpen}
2932
className={classNames(styles.modal, className)}

packages/experience/src/components/ConfirmModal/type.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export type ModalProps = {
1111
confirmText?: TFuncKey;
1212
cancelTextI18nProps?: Record<string, string>;
1313
confirmTextI18nProps?: Record<string, string>;
14+
shouldCloseOnOverlayClick?: boolean;
15+
shouldCloseOnEsc?: boolean;
1416
onConfirm?: () => void;
1517
onClose: () => void;
1618
};

packages/experience/src/hooks/use-terms.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const useTerms = () => {
2525

2626
const termsAndPrivacyConfirmModalHandler = useCallback(async () => {
2727
const [result] = await show({
28+
shouldCloseOnOverlayClick: false,
2829
ModalContent: TermsAndPrivacyConfirmModalContent,
2930
confirmText: 'action.agree',
3031
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { TFuncKey } from 'i18next';
2+
import { useLocation } from 'react-router-dom';
3+
import { define, object, optional, string, validate } from 'superstruct';
4+
5+
import ErrorPage from '@/pages/ErrorPage';
6+
7+
// Runtime guard for i18n key strings while preserving TFuncKey typing at compile-time
8+
const tFunctionKey = define<TFuncKey>('TFuncKey', (value) => typeof value === 'string');
9+
const stateGuard = object({
10+
title: optional(tFunctionKey),
11+
message: optional(tFunctionKey),
12+
errorMessage: optional(string()),
13+
});
14+
15+
const Error = () => {
16+
const { state } = useLocation();
17+
const [, parsed] = validate(state, stateGuard);
18+
19+
return (
20+
<ErrorPage
21+
isNavbarHidden
22+
title={parsed?.title ?? 'error.invalid_link'}
23+
message={parsed?.message ?? 'error.invalid_link_description'}
24+
rawMessage={parsed?.errorMessage}
25+
/>
26+
);
27+
};
28+
29+
export default Error;
Lines changed: 85 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
import {
22
AgreeToTermsPolicy,
3+
experience,
34
ExtraParamsKey,
45
InteractionEvent,
56
SignInIdentifier,
67
type RequestErrorBody,
78
} from '@logto/schemas';
8-
import { condString } from '@silverhand/essentials';
9-
import { useCallback, useContext, useEffect, useState } from 'react';
10-
import { useSearchParams } from 'react-router-dom';
9+
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
10+
import { useNavigate, useSearchParams } from 'react-router-dom';
1111

1212
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
1313
import {
1414
identifyAndSubmitInteraction,
15-
signInWithVerifiedIdentifier,
16-
registerWithOneTimeToken,
15+
registerWithVerifiedIdentifier,
16+
signInWithOneTimeToken,
1717
} from '@/apis/experience';
1818
import LoadingLayer from '@/components/LoadingLayer';
1919
import useApi from '@/hooks/use-api';
@@ -22,15 +22,16 @@ import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
2222
import useSubmitInteractionErrorHandler from '@/hooks/use-submit-interaction-error-handler';
2323
import useTerms from '@/hooks/use-terms';
2424

25-
import ErrorPage from '../ErrorPage';
26-
2725
const OneTimeToken = () => {
2826
const [params] = useSearchParams();
29-
const [oneTimeTokenError, setOneTimeTokenError] = useState<string | boolean>();
27+
const navigate = useNavigate();
28+
const [isLoading, setIsLoading] = useState(false);
29+
const hasTermsAgreed = useRef(false);
30+
const isSubmitted = useRef(false);
3031

3132
const asyncIdentifyUserAndSubmit = useApi(identifyAndSubmitInteraction);
32-
const asyncSignInWithVerifiedIdentifier = useApi(signInWithVerifiedIdentifier);
33-
const asyncRegisterWithOneTimeToken = useApi(registerWithOneTimeToken);
33+
const asyncSignInWithOneTimeToken = useApi(signInWithOneTimeToken);
34+
const asyncRegisterWithVerifiedIdentifier = useApi(registerWithVerifiedIdentifier);
3435

3536
const { setIdentifierInputValue } = useContext(UserInteractionContext);
3637
const { termsValidation, agreeToTermsPolicy } = useTerms();
@@ -40,22 +41,43 @@ const OneTimeToken = () => {
4041
const preRegisterErrorHandler = useSubmitInteractionErrorHandler(InteractionEvent.Register);
4142

4243
/**
43-
* Update interaction event to `SignIn`, and then identify user and submit.
44+
* Update interaction event to `Register`, and then identify user and submit.
4445
*/
45-
const signInWithOneTimeToken = useCallback(
46+
const registerWithOneTimeToken = useCallback(
4647
async (verificationId: string) => {
47-
const [error, result] = await asyncSignInWithVerifiedIdentifier(verificationId);
48+
if (
49+
!hasTermsAgreed.current &&
50+
agreeToTermsPolicy !== AgreeToTermsPolicy.Automatic &&
51+
!(await termsValidation())
52+
) {
53+
navigate(
54+
{ pathname: `/${experience.routes.oneTimeToken}/error` },
55+
{ replace: true, state: { errorMessage: 'terms_acceptance_required_description' } }
56+
);
57+
return;
58+
}
59+
60+
setIsLoading(true);
61+
const [error, result] = await asyncRegisterWithVerifiedIdentifier(verificationId);
4862

4963
if (error) {
50-
await handleError(error, preSignInErrorHandler);
64+
await handleError(error, preRegisterErrorHandler);
5165
return;
5266
}
5367

5468
if (result?.redirectTo) {
5569
await redirectTo(result.redirectTo);
5670
}
5771
},
58-
[preSignInErrorHandler, asyncSignInWithVerifiedIdentifier, handleError, redirectTo]
72+
[
73+
agreeToTermsPolicy,
74+
preRegisterErrorHandler,
75+
asyncRegisterWithVerifiedIdentifier,
76+
navigate,
77+
handleError,
78+
redirectTo,
79+
termsValidation,
80+
]
5981
);
6082

6183
/**
@@ -67,11 +89,12 @@ const OneTimeToken = () => {
6789
const [error, result] = await asyncIdentifyUserAndSubmit({ verificationId });
6890

6991
if (error) {
92+
setIsLoading(false);
7093
await handleError(error, {
71-
'user.email_already_in_use': async () => {
72-
await signInWithOneTimeToken(verificationId);
94+
'user.user_not_exist': async () => {
95+
await registerWithOneTimeToken(verificationId);
7396
},
74-
...preRegisterErrorHandler,
97+
...preSignInErrorHandler,
7598
});
7699
return;
77100
}
@@ -81,53 +104,82 @@ const OneTimeToken = () => {
81104
}
82105
},
83106
[
84-
preRegisterErrorHandler,
107+
preSignInErrorHandler,
85108
asyncIdentifyUserAndSubmit,
86109
handleError,
87110
redirectTo,
88-
signInWithOneTimeToken,
111+
registerWithOneTimeToken,
89112
]
90113
);
91114

115+
// Single effect: validate params, run terms gating once, then proceed to submission with idempotency
92116
useEffect(() => {
93117
(async () => {
94118
const token = params.get(ExtraParamsKey.OneTimeToken);
95119
const email = params.get(ExtraParamsKey.LoginHint);
96120
const errorMessage = params.get('errorMessage');
97121

98122
if (errorMessage) {
99-
setOneTimeTokenError(errorMessage);
123+
navigate(
124+
{ pathname: `/${experience.routes.oneTimeToken}/error` },
125+
{ replace: true, state: { errorMessage } }
126+
);
100127
return;
101128
}
102129

103130
if (!token || !email) {
104-
setOneTimeTokenError(true);
131+
navigate(`/${experience.routes.oneTimeToken}/error`, { replace: true });
105132
return;
106133
}
107134

108-
/**
109-
* Check if the user has agreed to the terms and privacy policy before navigating to the 3rd-party social sign-in page
110-
* when the policy is set to `Manual`
111-
*/
112-
if (agreeToTermsPolicy === AgreeToTermsPolicy.Manual && !(await termsValidation())) {
135+
if (agreeToTermsPolicy === AgreeToTermsPolicy.Manual) {
136+
const isAgreed = await termsValidation();
137+
138+
// eslint-disable-next-line @silverhand/fp/no-mutation
139+
hasTermsAgreed.current = isAgreed;
140+
141+
if (!isAgreed) {
142+
navigate(
143+
{ pathname: `/${experience.routes.oneTimeToken}/error` },
144+
{
145+
replace: true,
146+
state: {
147+
title: 'error.terms_acceptance_required',
148+
message: 'error.terms_acceptance_required_description',
149+
},
150+
}
151+
);
152+
return;
153+
}
154+
}
155+
156+
if (isSubmitted.current) {
113157
return;
114158
}
159+
// eslint-disable-next-line @silverhand/fp/no-mutation
160+
isSubmitted.current ||= true;
115161

116-
const [error, result] = await asyncRegisterWithOneTimeToken({
162+
setIsLoading(true);
163+
const [error, result] = await asyncSignInWithOneTimeToken({
117164
token,
118165
identifier: { type: SignInIdentifier.Email, value: email },
119166
});
120167

121168
if (error) {
169+
setIsLoading(false);
122170
await handleError(error, {
123171
global: (error: RequestErrorBody) => {
124-
setOneTimeTokenError(error.message);
172+
navigate(
173+
{ pathname: `/${experience.routes.oneTimeToken}/error` },
174+
{ replace: true, state: { errorMessage: error.message } }
175+
);
125176
},
126177
});
127178
return;
128179
}
129180

130181
if (!result?.verificationId) {
182+
setIsLoading(false);
131183
return;
132184
}
133185

@@ -136,28 +188,19 @@ const OneTimeToken = () => {
136188
setIdentifierInputValue({ type: SignInIdentifier.Email, value: email });
137189

138190
await submit(result.verificationId);
191+
setIsLoading(false);
139192
})();
140193
}, [
141194
agreeToTermsPolicy,
142195
params,
143-
asyncRegisterWithOneTimeToken,
196+
asyncSignInWithOneTimeToken,
144197
handleError,
198+
navigate,
145199
setIdentifierInputValue,
146200
submit,
147201
termsValidation,
148202
]);
149203

150-
if (oneTimeTokenError) {
151-
return (
152-
<ErrorPage
153-
isNavbarHidden
154-
title="error.invalid_link"
155-
message="error.invalid_link_description"
156-
rawMessage={condString(typeof oneTimeTokenError !== 'boolean' && oneTimeTokenError)}
157-
/>
158-
);
159-
}
160-
161-
return <LoadingLayer />;
204+
return isLoading ? <LoadingLayer /> : null;
162205
};
163206
export default OneTimeToken;

0 commit comments

Comments
 (0)