Skip to content

Commit 25df92c

Browse files
committed
feat(experience): add one-time token landing page to handle magic link auth
1 parent 985eb02 commit 25df92c

File tree

7 files changed

+180
-12
lines changed

7 files changed

+180
-12
lines changed

packages/experience/src/App.tsx

+6-3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import MfaVerification from './pages/MfaVerification';
2424
import BackupCodeVerification from './pages/MfaVerification/BackupCodeVerification';
2525
import TotpVerification from './pages/MfaVerification/TotpVerification';
2626
import WebAuthnVerification from './pages/MfaVerification/WebAuthnVerification';
27+
import OneTimeToken from './pages/OneTimeToken';
2728
import Register from './pages/Register';
2829
import RegisterPassword from './pages/RegisterPassword';
2930
import ResetPassword from './pages/ResetPassword';
@@ -37,6 +38,7 @@ import SocialLanding from './pages/SocialLanding';
3738
import SocialLinkAccount from './pages/SocialLinkAccount';
3839
import SocialSignInWebCallback from './pages/SocialSignInWebCallback';
3940
import Springboard from './pages/Springboard';
41+
import SwitchAccount from './pages/SwitchAccount';
4042
import VerificationCode from './pages/VerificationCode';
4143
import { UserMfaFlow } from './types';
4244
import { handleSearchParametersData } from './utils/search-parameters';
@@ -61,7 +63,7 @@ const App = () => {
6163
element={<SocialSignInWebCallback />}
6264
/>
6365
<Route path="direct/:method/:target?" element={<DirectSignIn />} />
64-
66+
<Route path="token/:token" element={<OneTimeToken />} />
6567
<Route element={<AppLayout />}>
6668
<Route
6769
path="unknown-session"
@@ -153,8 +155,9 @@ const App = () => {
153155
path={experience.routes.resetPassword}
154156
element={<ResetPasswordLanding />}
155157
/>
156-
157-
<Route path="*" element={<ErrorPage />} />
158+
<Route path={experience.routes.switchAccount} element={<SwitchAccount />} />
159+
<Route path={experience.routes.error} element={<ErrorPage />} />
160+
<Route path="*" element={<ErrorPage title="description.not_found" />} />
158161
</Route>
159162
</Route>
160163
</Routes>

packages/experience/src/apis/experience/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export {
2828

2929
export * from './mfa';
3030
export * from './social';
31+
export * from './one-time-token';
3132

3233
export const registerWithVerifiedIdentifier = async (verificationId: string) => {
3334
await updateInteractionEvent(InteractionEvent.Register);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { InteractionEvent, type OneTimeTokenVerificationVerifyPayload } from '@logto/schemas';
2+
3+
import api from '../api';
4+
5+
import { experienceApiRoutes, type VerificationResponse } from './const';
6+
import { initInteraction } from './interaction';
7+
8+
export const verifyOneTimeToken = async (payload: OneTimeTokenVerificationVerifyPayload) => {
9+
await initInteraction(InteractionEvent.Register);
10+
11+
return api
12+
.post(`${experienceApiRoutes.verification}/one-time-token/verify`, {
13+
json: payload,
14+
})
15+
.json<VerificationResponse>();
16+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { experience } from '@logto/schemas';
2+
import { useMemo } from 'react';
3+
4+
const useFallbackRoute = () =>
5+
useMemo(() => {
6+
const fallbackKey = new URLSearchParams(window.location.search).get('fallback');
7+
return (
8+
Object.entries(experience.routes).find(([key]) => key === fallbackKey)?.[1] ??
9+
experience.routes.signIn
10+
);
11+
}, []);
12+
13+
export default useFallbackRoute;

packages/experience/src/pages/DirectSignIn/index.tsx

+3-9
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { experience } from '@logto/schemas';
2-
import { useEffect, useMemo } from 'react';
1+
import { useEffect } from 'react';
32
import { useParams } from 'react-router-dom';
43

54
import LoadingLayer from '@/components/LoadingLayer';
65
import useSocial from '@/containers/SocialSignInList/use-social';
6+
import useFallbackRoute from '@/hooks/use-fallback-route';
77
import { useSieMethods } from '@/hooks/use-sie';
88
import useSingleSignOn from '@/hooks/use-single-sign-on';
99

@@ -12,13 +12,7 @@ const DirectSignIn = () => {
1212
const { socialConnectors, ssoConnectors } = useSieMethods();
1313
const { invokeSocialSignIn } = useSocial();
1414
const invokeSso = useSingleSignOn();
15-
const fallback = useMemo(() => {
16-
const fallbackKey = new URLSearchParams(window.location.search).get('fallback');
17-
return (
18-
Object.entries(experience.routes).find(([key]) => key === fallbackKey)?.[1] ??
19-
experience.routes.signIn
20-
);
21-
}, []);
15+
const fallback = useFallbackRoute();
2216

2317
useEffect(() => {
2418
if (method === 'social') {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { AgreeToTermsPolicy, SignInIdentifier } from '@logto/schemas';
2+
import { useCallback, useEffect, useState } from 'react';
3+
import { useParams } from 'react-router-dom';
4+
5+
import {
6+
identifyAndSubmitInteraction,
7+
signInWithVerifiedIdentifier,
8+
verifyOneTimeToken,
9+
} from '@/apis/experience';
10+
import LoadingLayer from '@/components/LoadingLayer';
11+
import useApi from '@/hooks/use-api';
12+
import useErrorHandler from '@/hooks/use-error-handler';
13+
import useFallbackRoute from '@/hooks/use-fallback-route';
14+
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
15+
import useLoginHint from '@/hooks/use-login-hint';
16+
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
17+
import useTerms from '@/hooks/use-terms';
18+
19+
import ErrorPage from '../ErrorPage';
20+
import SwitchAccount from '../SwitchAccount';
21+
22+
const OneTimeToken = () => {
23+
const { token } = useParams();
24+
const fallback = useFallbackRoute();
25+
const email = useLoginHint();
26+
const [mismatchedAccount, setMismatchedAccount] = useState<string>();
27+
const [oneTimeTokenError, setOneTimeTokenError] = useState<unknown>();
28+
29+
const asyncRegisterWithOneTimeToken = useApi(identifyAndSubmitInteraction);
30+
const asyncSignInWithVerifiedIdentifier = useApi(signInWithVerifiedIdentifier);
31+
const asyncVerifyOneTimeToken = useApi(verifyOneTimeToken);
32+
33+
const { termsValidation, agreeToTermsPolicy } = useTerms();
34+
const handleError = useErrorHandler();
35+
const redirectTo = useGlobalRedirectTo();
36+
const preSignInErrorHandler = usePreSignInErrorHandler();
37+
38+
const signInWithOneTimeToken = useCallback(
39+
async (verificationId: string) => {
40+
const [error, result] = await asyncSignInWithVerifiedIdentifier(verificationId);
41+
42+
if (error) {
43+
await handleError(error, preSignInErrorHandler);
44+
return;
45+
}
46+
47+
if (result?.redirectTo) {
48+
await redirectTo(result.redirectTo);
49+
}
50+
},
51+
[preSignInErrorHandler, asyncSignInWithVerifiedIdentifier, handleError, redirectTo]
52+
);
53+
54+
const registerWithOneTimeToken = useCallback(
55+
async (verificationId: string) => {
56+
const [error, result] = await asyncRegisterWithOneTimeToken({ verificationId });
57+
58+
if (error) {
59+
await handleError(error, {
60+
'user.email_already_in_use': async () => {
61+
await signInWithOneTimeToken(verificationId);
62+
},
63+
});
64+
return;
65+
}
66+
67+
if (result?.redirectTo) {
68+
await redirectTo(result.redirectTo);
69+
}
70+
},
71+
[asyncRegisterWithOneTimeToken, handleError, redirectTo, signInWithOneTimeToken]
72+
);
73+
74+
useEffect(() => {
75+
(async () => {
76+
if (token && email) {
77+
/**
78+
* Check if the user has agreed to the terms and privacy policy before navigating to the 3rd-party social sign-in page
79+
* when the policy is set to `Manual`
80+
*/
81+
if (agreeToTermsPolicy === AgreeToTermsPolicy.Manual && !(await termsValidation())) {
82+
return;
83+
}
84+
const [error, result] = await asyncVerifyOneTimeToken({
85+
token,
86+
identifier: { type: SignInIdentifier.Email, value: email },
87+
});
88+
89+
if (error) {
90+
await handleError(error, {
91+
'one_time_token.email_mismatch': () => {
92+
setMismatchedAccount(email);
93+
},
94+
'one_time_token.token_expired': () => {
95+
setOneTimeTokenError(error);
96+
},
97+
'one_time_token.token_consumed': () => {
98+
setOneTimeTokenError(error);
99+
},
100+
'one_time_token.token_revoked': () => {
101+
setOneTimeTokenError(error);
102+
},
103+
'one_time_token.token_not_found': () => {
104+
setOneTimeTokenError(error);
105+
},
106+
});
107+
return;
108+
}
109+
110+
if (!result?.verificationId) {
111+
return;
112+
}
113+
await registerWithOneTimeToken(result.verificationId);
114+
}
115+
116+
window.location.replace('/' + fallback);
117+
})();
118+
}, [
119+
agreeToTermsPolicy,
120+
email,
121+
fallback,
122+
token,
123+
asyncVerifyOneTimeToken,
124+
handleError,
125+
registerWithOneTimeToken,
126+
termsValidation,
127+
]);
128+
129+
if (mismatchedAccount) {
130+
return <SwitchAccount account={mismatchedAccount} />;
131+
}
132+
133+
if (oneTimeTokenError) {
134+
return <ErrorPage title="error.invalid_link" message="error.invalid_link_description" />;
135+
}
136+
137+
return <LoadingLayer />;
138+
};
139+
export default OneTimeToken;

packages/schemas/src/consts/experience.ts

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ const routes = Object.freeze({
66
resetPassword: 'reset-password',
77
identifierSignIn: 'identifier-sign-in',
88
identifierRegister: 'identifier-register',
9+
switchAccount: 'switch-account',
10+
error: 'error',
911
} as const);
1012

1113
export const experience = Object.freeze({

0 commit comments

Comments
 (0)