Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/experience/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import LoadingLayerProvider from './Providers/LoadingLayerProvider';
import PageContextProvider from './Providers/PageContextProvider';
import SettingsProvider from './Providers/SettingsProvider';
import UserInteractionContextProvider from './Providers/UserInteractionContextProvider';
import { isDevFeaturesEnabled } from './constants/env';
import DevelopmentTenantNotification from './containers/DevelopmentTenantNotification';
import Callback from './pages/Callback';
import Consent from './pages/Consent';
Expand All @@ -24,6 +25,7 @@ import MfaVerification from './pages/MfaVerification';
import BackupCodeVerification from './pages/MfaVerification/BackupCodeVerification';
import TotpVerification from './pages/MfaVerification/TotpVerification';
import WebAuthnVerification from './pages/MfaVerification/WebAuthnVerification';
import OneTimeToken from './pages/OneTimeToken';
import Register from './pages/Register';
import RegisterPassword from './pages/RegisterPassword';
import ResetPassword from './pages/ResetPassword';
Expand Down Expand Up @@ -61,8 +63,10 @@ const App = () => {
element={<SocialSignInWebCallback />}
/>
<Route path="direct/:method/:target?" element={<DirectSignIn />} />

<Route element={<AppLayout />}>
{isDevFeaturesEnabled && (
<Route path="one-time-token/:token" element={<OneTimeToken />} />
)}
<Route
path="unknown-session"
element={<ErrorPage message="error.invalid_session" />}
Expand Down Expand Up @@ -153,7 +157,6 @@ const App = () => {
path={experience.routes.resetPassword}
element={<ResetPasswordLanding />}
/>

<Route path="*" element={<ErrorPage />} />
</Route>
</Route>
Expand Down
1 change: 1 addition & 0 deletions packages/experience/src/apis/experience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export {

export * from './mfa';
export * from './social';
export * from './one-time-token';

export const registerWithVerifiedIdentifier = async (verificationId: string) => {
await updateInteractionEvent(InteractionEvent.Register);
Expand Down
16 changes: 16 additions & 0 deletions packages/experience/src/apis/experience/one-time-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { InteractionEvent, type OneTimeTokenVerificationVerifyPayload } from '@logto/schemas';

import api from '../api';

import { experienceApiRoutes, type VerificationResponse } from './const';
import { initInteraction } from './interaction';

export const registerWithOneTimeToken = async (payload: OneTimeTokenVerificationVerifyPayload) => {
await initInteraction(InteractionEvent.Register);

return api
.post(`${experienceApiRoutes.verification}/one-time-token/verify`, {
json: payload,
})
.json<VerificationResponse>();
};
13 changes: 13 additions & 0 deletions packages/experience/src/hooks/use-fallback-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { experience } from '@logto/schemas';
import { useMemo } from 'react';

const useFallbackRoute = () =>
useMemo(() => {
const fallbackKey = new URLSearchParams(window.location.search).get('fallback');
Comment thread
charIeszhao marked this conversation as resolved.
return (
Object.entries(experience.routes).find(([key]) => key === fallbackKey)?.[1] ??
experience.routes.signIn
);
}, []);

export default useFallbackRoute;
12 changes: 3 additions & 9 deletions packages/experience/src/pages/DirectSignIn/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { experience } from '@logto/schemas';
import { useEffect, useMemo } from 'react';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';

import LoadingLayer from '@/components/LoadingLayer';
import useSocial from '@/containers/SocialSignInList/use-social';
import useFallbackRoute from '@/hooks/use-fallback-route';
import { useSieMethods } from '@/hooks/use-sie';
import useSingleSignOn from '@/hooks/use-single-sign-on';

Expand All @@ -12,13 +12,7 @@ const DirectSignIn = () => {
const { socialConnectors, ssoConnectors } = useSieMethods();
const { invokeSocialSignIn } = useSocial();
const invokeSso = useSingleSignOn();
const fallback = useMemo(() => {
const fallbackKey = new URLSearchParams(window.location.search).get('fallback');
return (
Object.entries(experience.routes).find(([key]) => key === fallbackKey)?.[1] ??
experience.routes.signIn
);
}, []);
const fallback = useFallbackRoute();

useEffect(() => {
if (method === 'social') {
Expand Down
150 changes: 150 additions & 0 deletions packages/experience/src/pages/OneTimeToken/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {
AgreeToTermsPolicy,
InteractionEvent,
SignInIdentifier,
type RequestErrorBody,
} from '@logto/schemas';
import { condString } from '@silverhand/essentials';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';

import {
identifyAndSubmitInteraction,
signInWithVerifiedIdentifier,
registerWithOneTimeToken,
} from '@/apis/experience';
import LoadingLayer from '@/components/LoadingLayer';
import useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler';
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
import useLoginHint from '@/hooks/use-login-hint';
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
import useTerms from '@/hooks/use-terms';

import ErrorPage from '../ErrorPage';

const OneTimeToken = () => {
const { token } = useParams();
const email = useLoginHint();
const [oneTimeTokenError, setOneTimeTokenError] = useState<RequestErrorBody | boolean>();

const asyncIdentifyUserAndSubmit = useApi(identifyAndSubmitInteraction);
const asyncSignInWithVerifiedIdentifier = useApi(signInWithVerifiedIdentifier);
const asyncRegisterWithOneTimeToken = useApi(registerWithOneTimeToken);

const { termsValidation, agreeToTermsPolicy } = useTerms();
const handleError = useErrorHandler();
const redirectTo = useGlobalRedirectTo();
const preSignInErrorHandler = usePreSignInErrorHandler();
const preRegisterErrorHandler = usePreSignInErrorHandler({
interactionEvent: InteractionEvent.Register,
});

/**
* Update interaction event to `SignIn`, and then identify user and submit.
*/
const signInWithOneTimeToken = useCallback(
async (verificationId: string) => {
const [error, result] = await asyncSignInWithVerifiedIdentifier(verificationId);

if (error) {
await handleError(error, preSignInErrorHandler);
return;
}

if (result?.redirectTo) {
await redirectTo(result.redirectTo);
}
},
[preSignInErrorHandler, asyncSignInWithVerifiedIdentifier, handleError, redirectTo]
);

/**
* Always try to submit the one-time token interaction with `Register` event first.
* If the email already exists, call the `signInWithOneTimeToken` function instead.
*/
const submit = useCallback(
async (verificationId: string) => {
const [error, result] = await asyncIdentifyUserAndSubmit({ verificationId });

if (error) {
await handleError(error, {
'user.email_already_in_use': async () => {
await signInWithOneTimeToken(verificationId);
},
...preRegisterErrorHandler,
});
return;
}

if (result?.redirectTo) {
await redirectTo(result.redirectTo);
}
},
[
preRegisterErrorHandler,
asyncIdentifyUserAndSubmit,
handleError,
redirectTo,
signInWithOneTimeToken,
]
);

useEffect(() => {
(async () => {
if (!token || !email) {
setOneTimeTokenError(true);
return;
}

/**
* Check if the user has agreed to the terms and privacy policy before navigating to the 3rd-party social sign-in page
* when the policy is set to `Manual`
*/
if (agreeToTermsPolicy === AgreeToTermsPolicy.Manual && !(await termsValidation())) {
return;
}

const [error, result] = await asyncRegisterWithOneTimeToken({
token,
identifier: { type: SignInIdentifier.Email, value: email },
});

if (error) {
await handleError(error, {
global: (error: RequestErrorBody) => {
setOneTimeTokenError(error);
},
});
return;
}

if (!result?.verificationId) {
return;
}

await submit(result.verificationId);
})();
}, [
agreeToTermsPolicy,
email,
token,
asyncRegisterWithOneTimeToken,
handleError,
termsValidation,
submit,
]);

if (oneTimeTokenError) {
return (
<ErrorPage
title="error.invalid_link"
message="error.invalid_link_description"
rawMessage={condString(typeof oneTimeTokenError !== 'boolean' && oneTimeTokenError.message)}
/>
);
}

return <LoadingLayer />;
};
export default OneTimeToken;
21 changes: 16 additions & 5 deletions packages/experience/src/utils/search-parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export const searchKeys = Object.freeze({
appId: 'app_id',
} satisfies Record<SearchKeysCamelCase, string>);

/**
* The one-time token used as verification method.
* Example usage: Magic link
*/
export const oneTimeTokenSearchKey = 'one_time_token';

export const handleSearchParametersData = () => {
const { search } = window.location;

Expand All @@ -34,9 +40,14 @@ export const handleSearchParametersData = () => {
}
}

window.history.replaceState(
{},
'',
window.location.pathname + condString(parameters.size > 0 && `?${parameters.toString()}`)
);
const conditionalParamString = condString(parameters.size > 0 && `?${parameters.toString()}`);

// Check one-time token existence, and redirect to the `/one-time-token` route.
const oneTimeToken = parameters.get(oneTimeTokenSearchKey);
if (oneTimeToken) {
window.history.replaceState({}, '', '/one-time-token/' + oneTimeToken + conditionalParamString);
return;
}

window.history.replaceState({}, '', window.location.pathname + conditionalParamString);
};
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/ar/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const error = {
timeout: 'انتهت مهلة الطلب. يرجى المحاولة مرة أخرى لاحقًا.',
password_rejected,
sso_not_enabled: 'تسجيل الدخول الموحد غير ممكّن لحساب البريد الإلكتروني هذا.',
invalid_link: 'رابط غير صالح',
invalid_link_description: 'ربما يكون رمز الدخول المؤقت قد انتهى أو لم يعد صالحًا.',
something_went_wrong: 'حدث خطأ ما.',
};

export default Object.freeze(error);
4 changes: 4 additions & 0 deletions packages/phrases-experience/src/locales/de/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const error = {
timeout: 'Zeitüberschreitung. Bitte melde dich erneut an.',
password_rejected,
sso_not_enabled: 'Single Sign-On ist für dieses E-Mail-Konto nicht aktiviert.',
invalid_link: 'Ungültiger Link',
invalid_link_description:
'Dein einmaliger Token ist möglicherweise abgelaufen oder nicht mehr gültig.',
something_went_wrong: 'Etwas ist schiefgegangen.',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/en/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const error = {
timeout: 'Request timeout. Please try again later.',
password_rejected,
sso_not_enabled: 'Single Sign-On is not enabled for this email account.',
invalid_link: 'Invalid link',
invalid_link_description: 'Your one-time token may have expired or is no longer valid.',
something_went_wrong: 'Something went wrong.',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/es/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const error = {
password_rejected,
sso_not_enabled:
'El inicio de sesión único no está habilitado para esta cuenta de correo electrónico.',
invalid_link: 'Enlace no válido',
invalid_link_description: 'Tu token de un solo uso puede haber expirado o ya no ser válido.',
something_went_wrong: 'Algo salió mal.',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/fr/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const error = {
timeout: "Délai d'attente de la requête dépassé. Veuillez réessayer plus tard.",
password_rejected,
sso_not_enabled: "La authentification unique n'est pas activée pour ce compte de messagerie.",
invalid_link: 'Lien invalide',
invalid_link_description: "Votre jeton à usage unique a peut-être expiré ou n'est plus valide.",
something_went_wrong: 'Quelque chose a mal tourné.',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/it/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const error = {
timeout: 'Timeout della richiesta. Si prega di riprovare più tardi.',
password_rejected,
sso_not_enabled: 'Single sign-on non abilitato per questo account email.',
invalid_link: 'Link invalido',
invalid_link_description: 'Il tuo token monouso potrebbe essere scaduto o non è più valido.',
something_went_wrong: 'Qualcosa è andato storto.',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/ja/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const error = {
timeout: 'リクエストタイムアウト。後でもう一度お試しください。',
password_rejected,
sso_not_enabled: 'このメールアカウントではシングルサインオンが有効になっていません。',
invalid_link: '無効なリンク',
invalid_link_description: 'ワンタイムトークンの有効期限が切れているか、無効になっています。',
something_went_wrong: '問題が発生しました。',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/ko/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const error = {
timeout: '요청 시간이 초과되었어요. 잠시 후에 다시 시도해 주세요.',
password_rejected,
sso_not_enabled: '이 이메일 계정에 대해 단일 로그인이 활성화되지 않았어요.',
invalid_link: '유효하지 않은 링크',
invalid_link_description: '일회성 토큰이 만료되었거나 더 이상 유효하지 않을 수 있어요.',
something_went_wrong: '문제가 발생했어요.',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/pl-pl/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const error = {
timeout: 'Czas żądania upłynął. Proszę spróbuj ponownie później.',
password_rejected,
sso_not_enabled: 'Pojedyncze logowanie nie jest włączony dla tego konta e-mail.',
invalid_link: 'Nieprawidłowy link',
invalid_link_description: 'Twój jednorazowy token mógł wygasnąć lub nie jest już ważny.',
something_went_wrong: 'Coś poszło nie tak.',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/pt-br/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const error = {
timeout: 'Tempo limite excedido. Por favor, tente novamente mais tarde.',
password_rejected,
sso_not_enabled: 'O Single Sign-On não está habilitado para esta conta de e-mail.',
invalid_link: 'Link inválido',
invalid_link_description: 'Seu token de uso único pode ter expirado ou não é mais válido.',
something_went_wrong: 'Algo deu errado.',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/pt-pt/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const error = {
timeout: 'Tempo limite de sessão. Volte e faça login novamente.',
password_rejected,
sso_not_enabled: 'O Single Sign-On não está habilitado para esta conta de e-mail.',
invalid_link: 'Link inválido',
invalid_link_description: 'O teu token de uso único pode ter expirado ou já não ser válido.',
something_went_wrong: 'Algo correu mal.',
};

export default Object.freeze(error);
Loading