Skip to content

Commit 3ac95d2

Browse files
committed
refactor: update per review comments
1 parent 5fd8e44 commit 3ac95d2

File tree

20 files changed

+141
-65
lines changed

20 files changed

+141
-65
lines changed

packages/experience/src/App.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import LoadingLayerProvider from './Providers/LoadingLayerProvider';
77
import PageContextProvider from './Providers/PageContextProvider';
88
import SettingsProvider from './Providers/SettingsProvider';
99
import UserInteractionContextProvider from './Providers/UserInteractionContextProvider';
10+
import { isDevFeaturesEnabled } from './constants/env';
1011
import DevelopmentTenantNotification from './containers/DevelopmentTenantNotification';
1112
import Callback from './pages/Callback';
1213
import Consent from './pages/Consent';
@@ -62,8 +63,10 @@ const App = () => {
6263
element={<SocialSignInWebCallback />}
6364
/>
6465
<Route path="direct/:method/:target?" element={<DirectSignIn />} />
65-
<Route path="token/:token" element={<OneTimeToken />} />
6666
<Route element={<AppLayout />}>
67+
{isDevFeaturesEnabled && (
68+
<Route path="one-time-token/:token" element={<OneTimeToken />} />
69+
)}
6770
<Route
6871
path="unknown-session"
6972
element={<ErrorPage message="error.invalid_session" />}
@@ -154,7 +157,7 @@ const App = () => {
154157
path={experience.routes.resetPassword}
155158
element={<ResetPasswordLanding />}
156159
/>
157-
<Route path="*" element={<ErrorPage title="description.not_found" />} />
160+
<Route path="*" element={<ErrorPage />} />
158161
</Route>
159162
</Route>
160163
</Routes>

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import api from '../api';
55
import { experienceApiRoutes, type VerificationResponse } from './const';
66
import { initInteraction } from './interaction';
77

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

1111
return api

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

+68-57
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,48 @@
1-
import { AgreeToTermsPolicy, SignInIdentifier } from '@logto/schemas';
1+
import {
2+
AgreeToTermsPolicy,
3+
InteractionEvent,
4+
SignInIdentifier,
5+
type RequestErrorBody,
6+
} from '@logto/schemas';
7+
import { condString } from '@silverhand/essentials';
28
import { useCallback, useEffect, useState } from 'react';
39
import { useParams } from 'react-router-dom';
410

511
import {
612
identifyAndSubmitInteraction,
713
signInWithVerifiedIdentifier,
8-
verifyOneTimeToken,
14+
registerWithOneTimeToken,
915
} from '@/apis/experience';
1016
import LoadingLayer from '@/components/LoadingLayer';
1117
import useApi from '@/hooks/use-api';
1218
import useErrorHandler from '@/hooks/use-error-handler';
13-
import useFallbackRoute from '@/hooks/use-fallback-route';
1419
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
1520
import useLoginHint from '@/hooks/use-login-hint';
1621
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
1722
import useTerms from '@/hooks/use-terms';
1823

1924
import ErrorPage from '../ErrorPage';
20-
import SwitchAccount from '../SwitchAccount';
2125

2226
const OneTimeToken = () => {
2327
const { token } = useParams();
24-
const fallback = useFallbackRoute();
2528
const email = useLoginHint();
26-
const [mismatchedAccount, setMismatchedAccount] = useState<string>();
27-
const [oneTimeTokenError, setOneTimeTokenError] = useState<unknown>();
29+
const [oneTimeTokenError, setOneTimeTokenError] = useState<RequestErrorBody | boolean>();
2830

29-
const asyncRegisterWithOneTimeToken = useApi(identifyAndSubmitInteraction);
31+
const asyncIdentifyUserAndSubmit = useApi(identifyAndSubmitInteraction);
3032
const asyncSignInWithVerifiedIdentifier = useApi(signInWithVerifiedIdentifier);
31-
const asyncVerifyOneTimeToken = useApi(verifyOneTimeToken);
33+
const asyncRegisterWithOneTimeToken = useApi(registerWithOneTimeToken);
3234

3335
const { termsValidation, agreeToTermsPolicy } = useTerms();
3436
const handleError = useErrorHandler();
3537
const redirectTo = useGlobalRedirectTo();
3638
const preSignInErrorHandler = usePreSignInErrorHandler();
39+
const preRegisterErrorHandler = usePreSignInErrorHandler({
40+
interactionEvent: InteractionEvent.Register,
41+
});
3742

43+
/**
44+
* Update interaction event to `SignIn`, and then identify user and submit.
45+
*/
3846
const signInWithOneTimeToken = useCallback(
3947
async (verificationId: string) => {
4048
const [error, result] = await asyncSignInWithVerifiedIdentifier(verificationId);
@@ -51,15 +59,20 @@ const OneTimeToken = () => {
5159
[preSignInErrorHandler, asyncSignInWithVerifiedIdentifier, handleError, redirectTo]
5260
);
5361

54-
const registerWithOneTimeToken = useCallback(
62+
/**
63+
* Always try to submit the one-time token interaction with `Register` event first.
64+
* If the email already exists, call the `signInWithOneTimeToken` function instead.
65+
*/
66+
const submit = useCallback(
5567
async (verificationId: string) => {
56-
const [error, result] = await asyncRegisterWithOneTimeToken({ verificationId });
68+
const [error, result] = await asyncIdentifyUserAndSubmit({ verificationId });
5769

5870
if (error) {
5971
await handleError(error, {
6072
'user.email_already_in_use': async () => {
6173
await signInWithOneTimeToken(verificationId);
6274
},
75+
...preRegisterErrorHandler,
6376
});
6477
return;
6578
}
@@ -68,70 +81,68 @@ const OneTimeToken = () => {
6881
await redirectTo(result.redirectTo);
6982
}
7083
},
71-
[asyncRegisterWithOneTimeToken, handleError, redirectTo, signInWithOneTimeToken]
84+
[
85+
preRegisterErrorHandler,
86+
asyncIdentifyUserAndSubmit,
87+
handleError,
88+
redirectTo,
89+
signInWithOneTimeToken,
90+
]
7291
);
7392

7493
useEffect(() => {
7594
(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 },
95+
if (!token || !email) {
96+
setOneTimeTokenError(true);
97+
return;
98+
}
99+
100+
/**
101+
* Check if the user has agreed to the terms and privacy policy before navigating to the 3rd-party social sign-in page
102+
* when the policy is set to `Manual`
103+
*/
104+
if (agreeToTermsPolicy === AgreeToTermsPolicy.Manual && !(await termsValidation())) {
105+
return;
106+
}
107+
108+
const [error, result] = await asyncRegisterWithOneTimeToken({
109+
token,
110+
identifier: { type: SignInIdentifier.Email, value: email },
111+
});
112+
113+
if (error) {
114+
await handleError(error, {
115+
global: (error: RequestErrorBody) => {
116+
setOneTimeTokenError(error);
117+
},
87118
});
119+
return;
120+
}
88121

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);
122+
if (!result?.verificationId) {
123+
return;
114124
}
115125

116-
window.location.replace('/' + fallback);
126+
await submit(result.verificationId);
117127
})();
118128
}, [
119129
agreeToTermsPolicy,
120130
email,
121-
fallback,
122131
token,
123-
asyncVerifyOneTimeToken,
132+
asyncRegisterWithOneTimeToken,
124133
handleError,
125-
registerWithOneTimeToken,
126134
termsValidation,
135+
submit,
127136
]);
128137

129-
if (mismatchedAccount) {
130-
return <SwitchAccount account={mismatchedAccount} />;
131-
}
132-
133138
if (oneTimeTokenError) {
134-
return <ErrorPage title="error.invalid_link" message="error.invalid_link_description" />;
139+
return (
140+
<ErrorPage
141+
title="error.invalid_link"
142+
message="error.invalid_link_description"
143+
rawMessage={condString(typeof oneTimeTokenError !== 'boolean' && oneTimeTokenError.message)}
144+
/>
145+
);
135146
}
136147

137148
return <LoadingLayer />;

packages/experience/src/utils/search-parameters.ts

+16-5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ export const searchKeys = Object.freeze({
1313
appId: 'app_id',
1414
} satisfies Record<SearchKeysCamelCase, string>);
1515

16+
/**
17+
* The one-time token used as verification method.
18+
* Example usage: Magic link
19+
*/
20+
export const oneTimeTokenSearchKey = 'one_time_token';
21+
1622
export const handleSearchParametersData = () => {
1723
const { search } = window.location;
1824

@@ -34,9 +40,14 @@ export const handleSearchParametersData = () => {
3440
}
3541
}
3642

37-
window.history.replaceState(
38-
{},
39-
'',
40-
window.location.pathname + condString(parameters.size > 0 && `?${parameters.toString()}`)
41-
);
43+
const conditionalParamString = condString(parameters.size > 0 && `?${parameters.toString()}`);
44+
45+
// Check one-time token existence, and redirect to the `/one-time-token` route.
46+
const oneTimeToken = parameters.get(oneTimeTokenSearchKey);
47+
if (oneTimeToken) {
48+
window.history.replaceState({}, '', '/one-time-token/' + oneTimeToken + conditionalParamString);
49+
return;
50+
}
51+
52+
window.history.replaceState({}, '', window.location.pathname + conditionalParamString);
4253
};

packages/phrases-experience/src/locales/ar/error/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ const error = {
1919
timeout: 'انتهت مهلة الطلب. يرجى المحاولة مرة أخرى لاحقًا.',
2020
password_rejected,
2121
sso_not_enabled: 'تسجيل الدخول الموحد غير ممكّن لحساب البريد الإلكتروني هذا.',
22+
invalid_link: 'رابط غير صالح',
23+
invalid_link_description: 'ربما يكون رمز الدخول المؤقت قد انتهى أو لم يعد صالحًا.',
24+
something_went_wrong: 'حدث خطأ ما.',
2225
};
2326

2427
export default Object.freeze(error);

packages/phrases-experience/src/locales/de/error/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ const error = {
1919
timeout: 'Zeitüberschreitung. Bitte melde dich erneut an.',
2020
password_rejected,
2121
sso_not_enabled: 'Single Sign-On ist für dieses E-Mail-Konto nicht aktiviert.',
22+
invalid_link: 'Ungültiger Link',
23+
invalid_link_description:
24+
'Dein einmaliger Token ist möglicherweise abgelaufen oder nicht mehr gültig.',
25+
something_went_wrong: 'Etwas ist schiefgegangen.',
2226
};
2327

2428
export default Object.freeze(error);

packages/phrases-experience/src/locales/en/error/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ const error = {
1919
timeout: 'Request timeout. Please try again later.',
2020
password_rejected,
2121
sso_not_enabled: 'Single Sign-On is not enabled for this email account.',
22+
invalid_link: 'Invalid link',
23+
invalid_link_description: 'Your one-time token may have expired or is no longer valid.',
24+
something_went_wrong: 'Something went wrong.',
2225
};
2326

2427
export default Object.freeze(error);

packages/phrases-experience/src/locales/es/error/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ const error = {
2121
password_rejected,
2222
sso_not_enabled:
2323
'El inicio de sesión único no está habilitado para esta cuenta de correo electrónico.',
24+
invalid_link: 'Enlace no válido',
25+
invalid_link_description: 'Tu token de un solo uso puede haber expirado o ya no ser válido.',
26+
something_went_wrong: 'Algo salió mal.',
2427
};
2528

2629
export default Object.freeze(error);

packages/phrases-experience/src/locales/fr/error/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ const error = {
2121
timeout: "Délai d'attente de la requête dépassé. Veuillez réessayer plus tard.",
2222
password_rejected,
2323
sso_not_enabled: "La authentification unique n'est pas activée pour ce compte de messagerie.",
24+
invalid_link: 'Lien invalide',
25+
invalid_link_description: "Votre jeton à usage unique a peut-être expiré ou n'est plus valide.",
26+
something_went_wrong: 'Quelque chose a mal tourné.',
2427
};
2528

2629
export default Object.freeze(error);

packages/phrases-experience/src/locales/it/error/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ const error = {
1919
timeout: 'Timeout della richiesta. Si prega di riprovare più tardi.',
2020
password_rejected,
2121
sso_not_enabled: 'Single sign-on non abilitato per questo account email.',
22+
invalid_link: 'Link invalido',
23+
invalid_link_description: 'Il tuo token monouso potrebbe essere scaduto o non è più valido.',
24+
something_went_wrong: 'Qualcosa è andato storto.',
2225
};
2326

2427
export default Object.freeze(error);

packages/phrases-experience/src/locales/ja/error/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ const error = {
2020
timeout: 'リクエストタイムアウト。後でもう一度お試しください。',
2121
password_rejected,
2222
sso_not_enabled: 'このメールアカウントではシングルサインオンが有効になっていません。',
23+
invalid_link: '無効なリンク',
24+
invalid_link_description: 'ワンタイムトークンの有効期限が切れているか、無効になっています。',
25+
something_went_wrong: '問題が発生しました。',
2326
};
2427

2528
export default Object.freeze(error);

packages/phrases-experience/src/locales/ko/error/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ const error = {
1919
timeout: '요청 시간이 초과되었어요. 잠시 후에 다시 시도해 주세요.',
2020
password_rejected,
2121
sso_not_enabled: '이 이메일 계정에 대해 단일 로그인이 활성화되지 않았어요.',
22+
invalid_link: '유효하지 않은 링크',
23+
invalid_link_description: '일회성 토큰이 만료되었거나 더 이상 유효하지 않을 수 있어요.',
24+
something_went_wrong: '문제가 발생했어요.',
2225
};
2326

2427
export default Object.freeze(error);

packages/phrases-experience/src/locales/pl-pl/error/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ const error = {
2020
timeout: 'Czas żądania upłynął. Proszę spróbuj ponownie później.',
2121
password_rejected,
2222
sso_not_enabled: 'Pojedyncze logowanie nie jest włączony dla tego konta e-mail.',
23+
invalid_link: 'Nieprawidłowy link',
24+
invalid_link_description: 'Twój jednorazowy token mógł wygasnąć lub nie jest już ważny.',
25+
something_went_wrong: 'Coś poszło nie tak.',
2326
};
2427

2528
export default Object.freeze(error);

packages/phrases-experience/src/locales/pt-br/error/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ const error = {
1919
timeout: 'Tempo limite excedido. Por favor, tente novamente mais tarde.',
2020
password_rejected,
2121
sso_not_enabled: 'O Single Sign-On não está habilitado para esta conta de e-mail.',
22+
invalid_link: 'Link inválido',
23+
invalid_link_description: 'Seu token de uso único pode ter expirado ou não é mais válido.',
24+
something_went_wrong: 'Algo deu errado.',
2225
};
2326

2427
export default Object.freeze(error);

packages/phrases-experience/src/locales/pt-pt/error/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ const error = {
2020
timeout: 'Tempo limite de sessão. Volte e faça login novamente.',
2121
password_rejected,
2222
sso_not_enabled: 'O Single Sign-On não está habilitado para esta conta de e-mail.',
23+
invalid_link: 'Link inválido',
24+
invalid_link_description: 'O teu token de uso único pode ter expirado ou já não ser válido.',
25+
something_went_wrong: 'Algo correu mal.',
2326
};
2427

2528
export default Object.freeze(error);

packages/phrases-experience/src/locales/ru/error/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ const error = {
2020
timeout: 'Время ожидания истекло. Пожалуйста, повторите попытку позднее.',
2121
password_rejected,
2222
sso_not_enabled: 'Односторонняя авторизация не включена для этого аккаунта электронной почты.',
23+
invalid_link: 'Неверная ссылка',
24+
invalid_link_description:
25+
'Ваш одноразовый токен мог истечь или больше не является действительным.',
26+
something_went_wrong: 'Что-то пошло не так.',
2327
};
2428

2529
export default Object.freeze(error);

packages/phrases-experience/src/locales/tr-tr/error/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ const error = {
1919
timeout: 'Oturum zaman aşımına uğradı. Lütfen geri dönüp tekrar giriş yapınız.',
2020
password_rejected,
2121
sso_not_enabled: 'Bu e-posta hesabı için tek oturum açma etkin değil.',
22+
invalid_link: 'Geçersiz bağlantı',
23+
invalid_link_description:
24+
'Tek kullanımlık belirtecin süresi dolmuş olabilir veya artık geçerli değil.',
25+
something_went_wrong: 'Bir şeyler yanlış gitti.',
2226
};
2327

2428
export default Object.freeze(error);

0 commit comments

Comments
 (0)