mirror of
https://github.com/logto-io/logto.git
synced 2025-02-24 22:05:56 -05:00
feat(experience): implement totp experience flow (#4589)
This commit is contained in:
parent
3e45c3848d
commit
f53778894d
44 changed files with 988 additions and 78 deletions
|
@ -45,6 +45,7 @@
|
|||
"@testing-library/react": "^14.0.0",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/jest": "^29.4.0",
|
||||
"@types/qrcode": "^1.5.2",
|
||||
"@types/react": "^18.0.31",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@types/react-helmet": "^6.1.6",
|
||||
|
@ -117,5 +118,8 @@
|
|||
"stylelint": {
|
||||
"extends": "@silverhand/eslint-config-react/.stylelintrc"
|
||||
},
|
||||
"prettier": "@silverhand/eslint-config/.prettierrc"
|
||||
"prettier": "@silverhand/eslint-config/.prettierrc",
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.3"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { AppInsightsBoundary } from '@logto/app-insights/react';
|
||||
import { MfaFactor } from '@logto/schemas';
|
||||
import { Route, Routes, BrowserRouter } from 'react-router-dom';
|
||||
|
||||
import AppLayout from './Layout/AppLayout';
|
||||
|
@ -6,11 +7,16 @@ import AppBoundary from './Providers/AppBoundary';
|
|||
import LoadingLayerProvider from './Providers/LoadingLayerProvider';
|
||||
import PageContextProvider from './Providers/PageContextProvider';
|
||||
import SettingsProvider from './Providers/SettingsProvider';
|
||||
import { isDevelopmentFeaturesEnabled } from './constants/env';
|
||||
import Callback from './pages/Callback';
|
||||
import Consent from './pages/Consent';
|
||||
import Continue from './pages/Continue';
|
||||
import ErrorPage from './pages/ErrorPage';
|
||||
import ForgotPassword from './pages/ForgotPassword';
|
||||
import MfaBinding from './pages/MfaBinding';
|
||||
import TotpBinding from './pages/MfaBinding/TotpBinding';
|
||||
import MfaVerification from './pages/MfaVerification';
|
||||
import TotpVerification from './pages/MfaVerification/TotpVerification';
|
||||
import Register from './pages/Register';
|
||||
import RegisterPassword from './pages/RegisterPassword';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
|
@ -21,6 +27,7 @@ import SocialLinkAccount from './pages/SocialLinkAccount';
|
|||
import SocialSignIn from './pages/SocialSignInCallback';
|
||||
import Springboard from './pages/Springboard';
|
||||
import VerificationCode from './pages/VerificationCode';
|
||||
import { UserMfaFlow } from './types';
|
||||
import { handleSearchParametersData } from './utils/search-parameters';
|
||||
|
||||
import './scss/normalized.scss';
|
||||
|
@ -66,6 +73,24 @@ const App = () => {
|
|||
{/* Passwordless verification code */}
|
||||
<Route path=":flow/verification-code" element={<VerificationCode />} />
|
||||
|
||||
{isDevelopmentFeaturesEnabled && (
|
||||
<>
|
||||
{/* Mfa binding */}
|
||||
{/* Todo @xiaoyijun reorg these routes when factors are all implemented */}
|
||||
<Route path={UserMfaFlow.MfaBinding}>
|
||||
<Route index element={<MfaBinding />} />
|
||||
<Route path={MfaFactor.TOTP} element={<TotpBinding />} />
|
||||
</Route>
|
||||
|
||||
{/* Mfa verification */}
|
||||
{/* Todo @xiaoyijun reorg these routes when factors are all implemented */}
|
||||
<Route path={UserMfaFlow.MfaVerification}>
|
||||
<Route index element={<MfaVerification />} />
|
||||
<Route path={MfaFactor.TOTP} element={<TotpVerification />} />
|
||||
</Route>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Continue set up missing profile */}
|
||||
<Route path="continue">
|
||||
<Route path=":method" element={<Continue />} />
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.title {
|
||||
font: var(--font-title-3);
|
||||
}
|
||||
|
||||
.description {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-type-secondary);
|
||||
margin-top: _.unit(1);
|
||||
}
|
30
packages/experience/src/Layout/SectionLayout/index.tsx
Normal file
30
packages/experience/src/Layout/SectionLayout/index.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { type TFuncKey } from 'i18next';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import DynamicT from '@/components/DynamicT';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
title: TFuncKey;
|
||||
description: TFuncKey;
|
||||
titleProps?: Record<string, unknown>;
|
||||
descriptionProps?: Record<string, unknown>;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const SectionLayout = ({ title, description, titleProps, descriptionProps, children }: Props) => {
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.title}>
|
||||
<DynamicT forKey={title} interpolation={titleProps} />
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
<DynamicT forKey={description} interpolation={descriptionProps} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionLayout;
|
|
@ -8,6 +8,8 @@ import type {
|
|||
SocialConnectorPayload,
|
||||
SocialEmailPayload,
|
||||
SocialPhonePayload,
|
||||
BindMfaPayload,
|
||||
VerifyMfaPayload,
|
||||
} from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
|
||||
|
@ -222,3 +224,18 @@ export const linkWithSocial = async (connectorId: string) => {
|
|||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
||||
export const createTotpSecret = async () =>
|
||||
api.post(`${interactionPrefix}/${verificationPath}/totp`).json<{ secret: string }>();
|
||||
|
||||
export const bindMfa = async (payload: BindMfaPayload) => {
|
||||
await api.put(`${interactionPrefix}/bind-mfa`, { json: payload });
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
||||
export const verifyMfa = async (payload: VerifyMfaPayload) => {
|
||||
await api.put(`${interactionPrefix}/mfa`, { json: payload });
|
||||
|
||||
return api.post(`${interactionPrefix}/submit`).json<Response>();
|
||||
};
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 16.6666H4.16666C3.94565 16.6666 3.73369 16.5788 3.57741 16.4225C3.42113 16.2663 3.33333 16.0543 3.33333 15.8333V4.16663C3.33333 3.94561 3.42113 3.73365 3.57741 3.57737C3.73369 3.42109 3.94565 3.33329 4.16666 3.33329H8.33333V5.83329C8.33333 6.49633 8.59672 7.13222 9.06556 7.60106C9.5344 8.0699 10.1703 8.33329 10.8333 8.33329H13.3333V9.16663C13.3333 9.38764 13.4211 9.5996 13.5774 9.75588C13.7337 9.91216 13.9457 9.99996 14.1667 9.99996C14.3877 9.99996 14.5996 9.91216 14.7559 9.75588C14.9122 9.5996 15 9.38764 15 9.16663V7.49996C15 7.49996 15 7.49996 15 7.44996C14.9913 7.3734 14.9746 7.29799 14.95 7.22496V7.14996C14.9099 7.06428 14.8565 6.98551 14.7917 6.91663L9.79166 1.91663C9.72278 1.85181 9.64401 1.79836 9.55833 1.75829C9.53346 1.75476 9.50821 1.75476 9.48333 1.75829C9.39868 1.70974 9.30518 1.67858 9.20833 1.66663H4.16666C3.50362 1.66663 2.86774 1.93002 2.3989 2.39886C1.93006 2.8677 1.66666 3.50358 1.66666 4.16663V15.8333C1.66666 16.4963 1.93006 17.1322 2.3989 17.6011C2.86774 18.0699 3.50362 18.3333 4.16666 18.3333H10C10.221 18.3333 10.433 18.2455 10.5893 18.0892C10.7455 17.9329 10.8333 17.721 10.8333 17.5C10.8333 17.2789 10.7455 17.067 10.5893 16.9107C10.433 16.7544 10.221 16.6666 10 16.6666ZM10 4.50829L12.1583 6.66663H10.8333C10.6123 6.66663 10.4004 6.57883 10.2441 6.42255C10.0878 6.26627 10 6.05431 10 5.83329V4.50829ZM5.83333 6.66663C5.61232 6.66663 5.40036 6.75442 5.24408 6.9107C5.0878 7.06698 5 7.27895 5 7.49996C5 7.72097 5.0878 7.93293 5.24408 8.08922C5.40036 8.2455 5.61232 8.33329 5.83333 8.33329H6.66666C6.88768 8.33329 7.09964 8.2455 7.25592 8.08922C7.4122 7.93293 7.5 7.72097 7.5 7.49996C7.5 7.27895 7.4122 7.06698 7.25592 6.9107C7.09964 6.75442 6.88768 6.66663 6.66666 6.66663H5.83333ZM18.0917 16.9083L17.1167 15.9416C17.4283 15.3977 17.553 14.7666 17.4718 14.1449C17.3905 13.5233 17.1077 12.9455 16.6667 12.5C16.2609 12.0797 15.7379 11.7914 15.1659 11.6726C14.5938 11.5538 13.9993 11.61 13.4597 11.8339C12.9201 12.0579 12.4605 12.4391 12.1406 12.928C11.8208 13.4169 11.6557 13.9908 11.6667 14.575C11.6638 15.0776 11.7924 15.5723 12.0397 16.0099C12.287 16.4475 12.6444 16.8129 13.0764 17.0698C13.5085 17.3267 14.0002 17.4661 14.5028 17.4744C15.0054 17.4826 15.5014 17.3593 15.9417 17.1166L16.9083 18.0916C16.9858 18.1697 17.078 18.2317 17.1795 18.274C17.2811 18.3163 17.39 18.3381 17.5 18.3381C17.61 18.3381 17.7189 18.3163 17.8205 18.274C17.922 18.2317 18.0142 18.1697 18.0917 18.0916C18.1698 18.0142 18.2318 17.922 18.2741 17.8204C18.3164 17.7189 18.3382 17.61 18.3382 17.5C18.3382 17.3899 18.3164 17.281 18.2741 17.1795C18.2318 17.0779 18.1698 16.9858 18.0917 16.9083ZM15.45 15.45C15.212 15.6738 14.8976 15.7984 14.5708 15.7984C14.2441 15.7984 13.9297 15.6738 13.6917 15.45C13.4623 15.2165 13.3336 14.9023 13.3333 14.575C13.3316 14.4106 13.3632 14.2476 13.4262 14.0958C13.4892 13.9439 13.5824 13.8065 13.7 13.6916C13.9222 13.4707 14.2201 13.3425 14.5333 13.3333C14.7018 13.3229 14.8706 13.3475 15.0291 13.4055C15.1877 13.4634 15.3325 13.5535 15.4546 13.6701C15.5766 13.7867 15.6733 13.9273 15.7385 14.083C15.8037 14.2387 15.8359 14.4062 15.8333 14.575C15.8264 14.9059 15.6886 15.2205 15.45 15.45ZM10.8333 9.99996H5.83333C5.61232 9.99996 5.40036 10.0878 5.24408 10.244C5.0878 10.4003 5 10.6123 5 10.8333C5 11.0543 5.0878 11.2663 5.24408 11.4225C5.40036 11.5788 5.61232 11.6666 5.83333 11.6666H10.8333C11.0543 11.6666 11.2663 11.5788 11.4226 11.4225C11.5789 11.2663 11.6667 11.0543 11.6667 10.8333C11.6667 10.6123 11.5789 10.4003 11.4226 10.244C11.2663 10.0878 11.0543 9.99996 10.8333 9.99996ZM9.16666 15C9.38768 15 9.59964 14.9122 9.75592 14.7559C9.9122 14.5996 10 14.3876 10 14.1666C10 13.9456 9.9122 13.7337 9.75592 13.5774C9.59964 13.4211 9.38768 13.3333 9.16666 13.3333H5.83333C5.61232 13.3333 5.40036 13.4211 5.24408 13.5774C5.0878 13.7337 5 13.9456 5 14.1666C5 14.3876 5.0878 14.5996 5.24408 14.7559C5.40036 14.9122 5.61232 15 5.83333 15H9.16666Z" fill="currentColor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 3.9 KiB |
3
packages/experience/src/assets/icons/factor-totp.svg
Normal file
3
packages/experience/src/assets/icons/factor-totp.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.5001 1.66663H12.5001C12.2791 1.66663 12.0671 1.75442 11.9108 1.9107C11.7545 2.06698 11.6667 2.27895 11.6667 2.49996C11.6667 2.72097 11.7545 2.93293 11.9108 3.08922C12.0671 3.2455 12.2791 3.33329 12.5001 3.33329H16.6667V7.49996C16.6667 7.72097 16.7545 7.93293 16.9108 8.08922C17.0671 8.2455 17.2791 8.33329 17.5001 8.33329C17.7211 8.33329 17.9331 8.2455 18.0893 8.08922C18.2456 7.93293 18.3334 7.72097 18.3334 7.49996V2.49996C18.3334 2.27895 18.2456 2.06698 18.0893 1.9107C17.9331 1.75442 17.7211 1.66663 17.5001 1.66663ZM17.5001 11.6666C17.2791 11.6666 17.0671 11.7544 16.9108 11.9107C16.7545 12.067 16.6667 12.2789 16.6667 12.5V16.6666H12.5001C12.2791 16.6666 12.0671 16.7544 11.9108 16.9107C11.7545 17.067 11.6667 17.2789 11.6667 17.5C11.6667 17.721 11.7545 17.9329 11.9108 18.0892C12.0671 18.2455 12.2791 18.3333 12.5001 18.3333H17.5001C17.7211 18.3333 17.9331 18.2455 18.0893 18.0892C18.2456 17.9329 18.3334 17.721 18.3334 17.5V12.5C18.3334 12.2789 18.2456 12.067 18.0893 11.9107C17.9331 11.7544 17.7211 11.6666 17.5001 11.6666ZM10.0001 4.99996C9.33704 4.99996 8.70116 5.26335 8.23231 5.73219C7.76347 6.20103 7.50008 6.83692 7.50008 7.49996V8.33329C7.05805 8.33329 6.63413 8.50889 6.32157 8.82145C6.00901 9.13401 5.83342 9.55793 5.83342 9.99996V13.3333C5.83342 13.7753 6.00901 14.1992 6.32157 14.5118C6.63413 14.8244 7.05805 15 7.50008 15H12.5001C12.9421 15 13.366 14.8244 13.6786 14.5118C13.9912 14.1992 14.1667 13.7753 14.1667 13.3333V9.99996C14.1667 9.55793 13.9912 9.13401 13.6786 8.82145C13.366 8.50889 12.9421 8.33329 12.5001 8.33329V7.49996C12.5001 6.83692 12.2367 6.20103 11.7678 5.73219C11.299 5.26335 10.6631 4.99996 10.0001 4.99996ZM9.16675 7.49996C9.16675 7.27895 9.25455 7.06698 9.41083 6.9107C9.56711 6.75442 9.77907 6.66663 10.0001 6.66663C10.2211 6.66663 10.4331 6.75442 10.5893 6.9107C10.7456 7.06698 10.8334 7.27895 10.8334 7.49996V8.33329H9.16675V7.49996ZM12.5001 13.3333H7.50008V9.99996H12.5001V13.3333ZM2.50008 8.33329C2.7211 8.33329 2.93306 8.2455 3.08934 8.08922C3.24562 7.93293 3.33341 7.72097 3.33341 7.49996V3.33329H7.50008C7.7211 3.33329 7.93306 3.2455 8.08934 3.08922C8.24562 2.93293 8.33342 2.72097 8.33342 2.49996C8.33342 2.27895 8.24562 2.06698 8.08934 1.9107C7.93306 1.75442 7.7211 1.66663 7.50008 1.66663H2.50008C2.27907 1.66663 2.06711 1.75442 1.91083 1.9107C1.75455 2.06698 1.66675 2.27895 1.66675 2.49996V7.49996C1.66675 7.72097 1.75455 7.93293 1.91083 8.08922C2.06711 8.2455 2.27907 8.33329 2.50008 8.33329ZM7.50008 16.6666H3.33341V12.5C3.33341 12.2789 3.24562 12.067 3.08934 11.9107C2.93306 11.7544 2.7211 11.6666 2.50008 11.6666C2.27907 11.6666 2.06711 11.7544 1.91083 11.9107C1.75455 12.067 1.66675 12.2789 1.66675 12.5V17.5C1.66675 17.721 1.75455 17.9329 1.91083 18.0892C2.06711 18.2455 2.27907 18.3333 2.50008 18.3333H7.50008C7.7211 18.3333 7.93306 18.2455 8.08934 18.0892C8.24562 17.9329 8.33342 17.721 8.33342 17.5C8.33342 17.2789 8.24562 17.067 8.08934 16.9107C7.93306 16.7544 7.7211 16.6666 7.50008 16.6666Z" fill="currentColor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 3 KiB |
19
packages/experience/src/assets/icons/factor-webauthn.svg
Normal file
19
packages/experience/src/assets/icons/factor-webauthn.svg
Normal file
|
@ -0,0 +1,19 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1158_5594)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 2.91659C10 2.68647 9.81345 2.49992 9.58333 2.49992H4.58333C4.35321 2.49992 4.16667 2.68647 4.16667 2.91659V5.83325H10V2.91659ZM4.16667 0.833252C3.24619 0.833252 2.5 1.57944 2.5 2.49992V5.83325C2.5 6.75373 3.24619 6.66658 4.16667 6.66658H10C10.9205 6.66658 11.6667 6.75373 11.6667 5.83325V2.49992C11.6667 1.57944 10.9205 0.833252 10 0.833252H4.16667Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.833336 7.49992C0.833336 6.57944 1.57953 5.83325 2.5 5.83325H11.6667C12.5871 5.83325 13.3333 6.57944 13.3333 7.49992L2.91667 7.49992C2.68655 7.49992 2.5 7.68647 2.5 7.91659V17.0833C2.5 17.3134 2.68655 17.4999 2.91667 17.4999H5C5.46024 17.4999 5.83334 17.873 5.83334 18.3333C5.83334 18.7935 5.46024 19.1666 5 19.1666H2.5C1.57953 19.1666 0.833336 18.4204 0.833336 17.4999V7.49992Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.83334 4.99992C5.3731 4.99992 5.00001 4.62682 5.00001 4.16659C5.00001 3.70635 5.3731 3.33325 5.83334 3.33325C6.29358 3.33325 6.66667 3.70635 6.66667 4.16659C6.66667 4.62682 6.29358 4.99992 5.83334 4.99992Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.33334 4.99992C7.8731 4.99992 7.5 4.62682 7.5 4.16659C7.5 3.70635 7.8731 3.33325 8.33334 3.33325C8.79358 3.33325 9.16667 3.70635 9.16667 4.16659C9.16667 4.62682 8.79358 4.99992 8.33334 4.99992Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.75 9.99992C11.6024 9.99992 10 11.5653 10 13.3333V18.3333C10 18.7935 9.62691 19.1666 9.16667 19.1666C8.70643 19.1666 8.33334 18.7935 8.33334 18.3333V13.3333C8.33334 10.4989 10.835 8.33325 13.75 8.33325C16.665 8.33325 19.1667 10.4989 19.1667 13.3333V14.9999C19.1667 15.4602 18.7936 15.8333 18.3333 15.8333C17.8731 15.8333 17.5 15.4602 17.5 14.9999V13.3333C17.5 11.5653 15.8976 9.99992 13.75 9.99992Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.75 12.4999C14.5169 12.4999 15 13.0477 15 13.5605V18.3333C15 18.7935 15.3731 19.1666 15.8333 19.1666C16.2936 19.1666 16.6667 18.7935 16.6667 18.3333V13.5605C16.6667 11.9813 15.2843 10.8333 13.75 10.8333C12.2157 10.8333 10.8333 11.9813 10.8333 13.5605V14.3181C10.8333 14.7783 11.2064 15.1514 11.6667 15.1514C12.1269 15.1514 12.5 14.7783 12.5 14.3181V13.5605C12.5 13.0477 12.9831 12.4999 13.75 12.4999Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.3333 16.6665C18.7936 16.6665 19.1667 17.0396 19.1667 17.4998L19.1667 18.3332C19.1667 18.7934 18.7936 19.1665 18.3333 19.1665C17.8731 19.1665 17.5 18.7934 17.5 18.3332L17.5 17.4998C17.5 17.0396 17.8731 16.6665 18.3333 16.6665Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.75 16.6665C14.2102 16.6665 14.5833 17.0396 14.5833 17.4998L14.5833 18.3332C14.5833 18.7934 14.2102 19.1665 13.75 19.1665C13.2898 19.1665 12.9167 18.7934 12.9167 18.3332L12.9167 17.4998C12.9167 17.0396 13.2898 16.6665 13.75 16.6665Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.6667 15.8333C12.1269 15.8333 12.5 16.2063 12.5 16.6666L12.5 18.3333C12.5 18.7935 12.1269 19.1666 11.6667 19.1666C11.2064 19.1666 10.8333 18.7935 10.8333 18.3333L10.8333 16.6666C10.8333 16.2063 11.2064 15.8333 11.6667 15.8333Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.75 13.3333C14.2102 13.3333 14.5833 13.7063 14.5833 14.1666V14.9999C14.5833 15.4602 14.2102 15.8333 13.75 15.8333C13.2898 15.8333 12.9167 15.4602 12.9167 14.9999V14.1666C12.9167 13.7063 13.2898 13.3333 13.75 13.3333Z" fill="currentColor"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1158_5594">
|
||||
<rect width="20" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 3.7 KiB |
|
@ -0,0 +1,29 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.mfaFactorButton {
|
||||
padding: _.unit(3) _.unit(4) _.unit(3) _.unit(3);
|
||||
height: unset;
|
||||
gap: _.unit(4);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--color-type-secondary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
@include _.flex-column;
|
||||
align-items: flex-start;
|
||||
|
||||
.name {
|
||||
font: var(--font-body-1);
|
||||
}
|
||||
|
||||
.description {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-type-secondary);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { type TFuncKey } from 'i18next';
|
||||
|
||||
import ArrowNext from '@/assets/icons/arrow-next.svg';
|
||||
import FactorBackupCode from '@/assets/icons/factor-backup-code.svg';
|
||||
import FactorTotp from '@/assets/icons/factor-totp.svg';
|
||||
import FactorWebAuthn from '@/assets/icons/factor-webauthn.svg';
|
||||
|
||||
import DynamicT from '../DynamicT';
|
||||
|
||||
import * as mfaFactorButtonStyles from './MfaFactorButton.module.scss';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Props = {
|
||||
factor: MfaFactor;
|
||||
isBinding: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const factorIcon: Record<MfaFactor, SvgComponent> = {
|
||||
[MfaFactor.TOTP]: FactorTotp,
|
||||
[MfaFactor.WebAuthn]: FactorWebAuthn,
|
||||
[MfaFactor.BackupCode]: FactorBackupCode,
|
||||
};
|
||||
|
||||
const factorName: Record<MfaFactor, TFuncKey> = {
|
||||
[MfaFactor.TOTP]: 'mfa.totp',
|
||||
[MfaFactor.WebAuthn]: 'mfa.webauthn',
|
||||
[MfaFactor.BackupCode]: 'mfa.backup_code',
|
||||
};
|
||||
|
||||
const factorDescription: Record<MfaFactor, TFuncKey> = {
|
||||
[MfaFactor.TOTP]: 'mfa.verify_totp_description',
|
||||
[MfaFactor.WebAuthn]: 'mfa.verify_webauthn_description',
|
||||
[MfaFactor.BackupCode]: 'mfa.verify_backup_code_description',
|
||||
};
|
||||
|
||||
const linkFactorDescription: Record<MfaFactor, TFuncKey> = {
|
||||
[MfaFactor.TOTP]: 'mfa.link_totp_description',
|
||||
[MfaFactor.WebAuthn]: 'mfa.link_webauthn_description',
|
||||
[MfaFactor.BackupCode]: 'mfa.link_backup_code_description',
|
||||
};
|
||||
|
||||
const MfaFactorButton = ({ factor, isBinding, onClick }: Props) => {
|
||||
const Icon = factorIcon[factor];
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
styles.button,
|
||||
styles.secondary,
|
||||
styles.large,
|
||||
mfaFactorButtonStyles.mfaFactorButton
|
||||
)}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className={mfaFactorButtonStyles.icon} />
|
||||
<div className={mfaFactorButtonStyles.title}>
|
||||
<div className={mfaFactorButtonStyles.name}>
|
||||
<DynamicT forKey={factorName[factor]} />
|
||||
</div>
|
||||
<div className={mfaFactorButtonStyles.description}>
|
||||
<DynamicT forKey={(isBinding ? linkFactorDescription : factorDescription)[factor]} />
|
||||
</div>
|
||||
</div>
|
||||
<ArrowNext className={mfaFactorButtonStyles.icon} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default MfaFactorButton;
|
|
@ -14,8 +14,12 @@ const Divider = ({ className, label }: Props) => {
|
|||
return (
|
||||
<div className={classNames(styles.divider, className)}>
|
||||
<i className={styles.line} />
|
||||
{label && <DynamicT forKey={label} />}
|
||||
<i className={styles.line} />
|
||||
{label && (
|
||||
<>
|
||||
<DynamicT forKey={label} />
|
||||
<i className={styles.line} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
4
packages/experience/src/constants/env.ts
Normal file
4
packages/experience/src/constants/env.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { yes } from '@silverhand/essentials';
|
||||
|
||||
export const isDevelopmentFeaturesEnabled =
|
||||
yes(process.env.DEV_FEATURES_ENABLED) || yes(process.env.INTEGRATION_TEST);
|
|
@ -0,0 +1,6 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.factorList {
|
||||
@include _.flex-column;
|
||||
gap: _.unit(3);
|
||||
}
|
54
packages/experience/src/containers/MfaFactorList/index.tsx
Normal file
54
packages/experience/src/containers/MfaFactorList/index.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import MfaFactorButton from '@/components/Button/MfaFactorButton';
|
||||
import useStartTotpBinding from '@/hooks/use-start-binding-totp';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { type TotpVerificationState } from '@/types/guard';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
flow: UserMfaFlow;
|
||||
factors: MfaFactor[];
|
||||
};
|
||||
|
||||
const MfaFactorList = ({ flow, factors }: Props) => {
|
||||
const startTotpBinding = useStartTotpBinding();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSelectFactor = useCallback(
|
||||
async (factor: MfaFactor) => {
|
||||
if (factor === MfaFactor.TOTP) {
|
||||
if (flow === UserMfaFlow.MfaBinding) {
|
||||
await startTotpBinding(factors.length > 1);
|
||||
}
|
||||
|
||||
if (flow === UserMfaFlow.MfaVerification) {
|
||||
const state: TotpVerificationState = { allowOtherFactors: true };
|
||||
navigate(`/${UserMfaFlow.MfaVerification}/${factor}`, { state });
|
||||
}
|
||||
}
|
||||
// Todo @xiaoyijun implement other factors
|
||||
},
|
||||
[factors.length, flow, navigate, startTotpBinding]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.factorList}>
|
||||
{factors.map((factor) => (
|
||||
<MfaFactorButton
|
||||
key={factor}
|
||||
factor={factor}
|
||||
isBinding={flow === UserMfaFlow.MfaBinding}
|
||||
onClick={() => {
|
||||
void handleSelectFactor(factor);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MfaFactorList;
|
|
@ -0,0 +1,5 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.totpCodeInput {
|
||||
margin-top: _.unit(4);
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import VerificationCodeInput from '@/components/VerificationCode';
|
||||
import { type UserMfaFlow } from '@/types';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
import useTotpCodeVerification from './use-totp-code-verification';
|
||||
|
||||
const totpCodeLength = 6;
|
||||
|
||||
type Options = {
|
||||
flow: UserMfaFlow;
|
||||
};
|
||||
|
||||
const TotpCodeVerification = ({ flow }: Options) => {
|
||||
const [code, setCode] = useState<string[]>([]);
|
||||
const { errorMessage, onSubmit } = useTotpCodeVerification({ flow });
|
||||
|
||||
return (
|
||||
<VerificationCodeInput
|
||||
name="totpCode"
|
||||
value={code}
|
||||
className={styles.totpCodeInput}
|
||||
error={errorMessage}
|
||||
onChange={(code) => {
|
||||
setCode(code);
|
||||
|
||||
if (code.length === totpCodeLength && code.every(Boolean)) {
|
||||
void onSubmit(code.join(''));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TotpCodeVerification;
|
|
@ -0,0 +1,68 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { bindMfa, verifyMfa } from '@/apis/interaction';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useErrorHandler, { type ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
|
||||
type Options = {
|
||||
flow: UserMfaFlow;
|
||||
};
|
||||
const useTotpCodeVerification = ({ flow }: Options) => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const asyncBindMfa = useApi(bindMfa);
|
||||
const asyncVerifyMfa = useApi(verifyMfa);
|
||||
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'session.mfa.invalid_totp_code': (error) => {
|
||||
setErrorMessage(error.message);
|
||||
},
|
||||
...preSignInErrorHandler,
|
||||
}),
|
||||
[preSignInErrorHandler]
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (code: string) => {
|
||||
// Todo @xiaoyijun refactor this logic
|
||||
if (flow === UserMfaFlow.MfaBinding) {
|
||||
const [error, result] = await asyncBindMfa({ type: MfaFactor.TOTP, code });
|
||||
if (error) {
|
||||
await handleError(error, errorHandlers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
window.location.replace(result.redirectTo);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify TOTP
|
||||
const [error, result] = await asyncVerifyMfa({ type: MfaFactor.TOTP, code });
|
||||
if (error) {
|
||||
await handleError(error, errorHandlers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
window.location.replace(result.redirectTo);
|
||||
}
|
||||
},
|
||||
[asyncBindMfa, asyncVerifyMfa, errorHandlers, flow, handleError]
|
||||
);
|
||||
|
||||
return {
|
||||
errorMessage,
|
||||
onSubmit,
|
||||
};
|
||||
};
|
||||
|
||||
export default useTotpCodeVerification;
|
|
@ -7,7 +7,7 @@ import { addProfileWithVerificationCodeIdentifier } from '@/apis/interaction';
|
|||
import useApi from '@/hooks/use-api';
|
||||
import type { ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
|
||||
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
|
||||
import type { VerificationCodeIdentifier } from '@/types';
|
||||
import { SearchParameters } from '@/types';
|
||||
|
||||
|
@ -27,7 +27,8 @@ const useContinueFlowCodeVerification = (
|
|||
|
||||
const { generalVerificationCodeErrorHandlers, errorMessage, clearErrorMessage } =
|
||||
useGeneralVerificationCodeErrorHandler();
|
||||
const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ replace: true });
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
|
||||
|
||||
const showIdentifierErrorAlert = useIdentifierErrorAlert();
|
||||
const showLinkSocialConfirmModal = useLinkSocialConfirmModal();
|
||||
const identifierExistErrorHandler = useCallback(
|
||||
|
@ -52,14 +53,14 @@ const useContinueFlowCodeVerification = (
|
|||
identifierExistErrorHandler(SignInIdentifier.Phone, target),
|
||||
'user.email_already_in_use': async () =>
|
||||
identifierExistErrorHandler(SignInIdentifier.Email, target),
|
||||
...requiredProfileErrorHandler,
|
||||
...preSignInErrorHandler,
|
||||
...generalVerificationCodeErrorHandlers,
|
||||
}),
|
||||
[
|
||||
target,
|
||||
identifierExistErrorHandler,
|
||||
requiredProfileErrorHandler,
|
||||
preSignInErrorHandler,
|
||||
generalVerificationCodeErrorHandlers,
|
||||
identifierExistErrorHandler,
|
||||
target,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import useApi from '@/hooks/use-api';
|
|||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import type { ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
|
||||
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
import type { VerificationCodeIdentifier } from '@/types';
|
||||
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
|
||||
|
@ -37,8 +37,8 @@ const useRegisterFlowCodeVerification = (
|
|||
|
||||
const { errorMessage, clearErrorMessage, generalVerificationCodeErrorHandlers } =
|
||||
useGeneralVerificationCodeErrorHandler();
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
|
||||
|
||||
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler({ replace: true });
|
||||
const showIdentifierErrorAlert = useIdentifierErrorAlert();
|
||||
const identifierExistErrorHandler = useCallback(async () => {
|
||||
// Should not redirect user to sign-in if is register-only mode
|
||||
|
@ -68,7 +68,7 @@ const useRegisterFlowCodeVerification = (
|
|||
const [error, result] = await signInWithIdentifierAsync();
|
||||
|
||||
if (error) {
|
||||
await handleError(error, requiredProfileErrorHandlers);
|
||||
await handleError(error, preSignInErrorHandler);
|
||||
|
||||
return;
|
||||
}
|
||||
|
@ -80,7 +80,7 @@ const useRegisterFlowCodeVerification = (
|
|||
handleError,
|
||||
method,
|
||||
navigate,
|
||||
requiredProfileErrorHandlers,
|
||||
preSignInErrorHandler,
|
||||
show,
|
||||
showIdentifierErrorAlert,
|
||||
signInMode,
|
||||
|
@ -94,14 +94,14 @@ const useRegisterFlowCodeVerification = (
|
|||
'user.email_already_in_use': identifierExistErrorHandler,
|
||||
'user.phone_already_in_use': identifierExistErrorHandler,
|
||||
...generalVerificationCodeErrorHandlers,
|
||||
...requiredProfileErrorHandlers,
|
||||
...preSignInErrorHandler,
|
||||
callback: errorCallback,
|
||||
}),
|
||||
[
|
||||
errorCallback,
|
||||
identifierExistErrorHandler,
|
||||
requiredProfileErrorHandlers,
|
||||
generalVerificationCodeErrorHandlers,
|
||||
preSignInErrorHandler,
|
||||
errorCallback,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import useApi from '@/hooks/use-api';
|
|||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import type { ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
|
||||
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
import type { VerificationCodeIdentifier } from '@/types';
|
||||
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
|
||||
|
@ -37,9 +37,8 @@ const useSignInFlowCodeVerification = (
|
|||
const { errorMessage, clearErrorMessage, generalVerificationCodeErrorHandlers } =
|
||||
useGeneralVerificationCodeErrorHandler();
|
||||
|
||||
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler({
|
||||
replace: true,
|
||||
});
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
|
||||
|
||||
const showIdentifierErrorAlert = useIdentifierErrorAlert();
|
||||
|
||||
const identifierNotExistErrorHandler = useCallback(async () => {
|
||||
|
@ -72,7 +71,7 @@ const useSignInFlowCodeVerification = (
|
|||
);
|
||||
|
||||
if (error) {
|
||||
await handleError(error, requiredProfileErrorHandlers);
|
||||
await handleError(error, preSignInErrorHandler);
|
||||
|
||||
return;
|
||||
}
|
||||
|
@ -85,7 +84,7 @@ const useSignInFlowCodeVerification = (
|
|||
method,
|
||||
navigate,
|
||||
registerWithIdentifierAsync,
|
||||
requiredProfileErrorHandlers,
|
||||
preSignInErrorHandler,
|
||||
show,
|
||||
showIdentifierErrorAlert,
|
||||
signInMode,
|
||||
|
@ -97,13 +96,13 @@ const useSignInFlowCodeVerification = (
|
|||
() => ({
|
||||
'user.user_not_exist': identifierNotExistErrorHandler,
|
||||
...generalVerificationCodeErrorHandlers,
|
||||
...requiredProfileErrorHandlers,
|
||||
...preSignInErrorHandler,
|
||||
callback: errorCallback,
|
||||
}),
|
||||
[
|
||||
errorCallback,
|
||||
identifierNotExistErrorHandler,
|
||||
requiredProfileErrorHandlers,
|
||||
preSignInErrorHandler,
|
||||
generalVerificationCodeErrorHandlers,
|
||||
]
|
||||
);
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import {
|
||||
type MfaFactorsState,
|
||||
missingMfaFactorsErrorDataGuard,
|
||||
requireMfaFactorsErrorDataGuard,
|
||||
type TotpVerificationState,
|
||||
} from '@/types/guard';
|
||||
|
||||
import type { ErrorHandlers } from './use-error-handler';
|
||||
import useStartTotpBinding from './use-start-binding-totp';
|
||||
import useToast from './use-toast';
|
||||
|
||||
export type Options = {
|
||||
replace?: boolean;
|
||||
};
|
||||
|
||||
const useMfaVerificationErrorHandler = ({ replace }: Options = {}) => {
|
||||
const navigate = useNavigate();
|
||||
const { setToast } = useToast();
|
||||
const startBindingTotp = useStartTotpBinding({ replace });
|
||||
|
||||
const mfaVerificationErrorHandler = useMemo<ErrorHandlers>(
|
||||
() => ({
|
||||
'user.missing_mfa': (error) => {
|
||||
const [_, data] = validate(error.data, missingMfaFactorsErrorDataGuard);
|
||||
const missingFactors = data?.missingFactors ?? [];
|
||||
|
||||
if (missingFactors.length === 0) {
|
||||
setToast(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (missingFactors.length > 1) {
|
||||
const state: MfaFactorsState = { factors: missingFactors };
|
||||
navigate({ pathname: `/${UserMfaFlow.MfaBinding}` }, { replace, state });
|
||||
return;
|
||||
}
|
||||
|
||||
const factor = missingFactors[0];
|
||||
|
||||
if (factor === MfaFactor.TOTP) {
|
||||
void startBindingTotp();
|
||||
}
|
||||
// Todo: @xiaoyijun handle other factors
|
||||
},
|
||||
'session.mfa.require_mfa_verification': async (error) => {
|
||||
const [_, data] = validate(error.data, requireMfaFactorsErrorDataGuard);
|
||||
const availableFactors = data?.availableFactors ?? [];
|
||||
if (availableFactors.length === 0) {
|
||||
setToast(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (availableFactors.length > 1) {
|
||||
const state: MfaFactorsState = { factors: availableFactors };
|
||||
navigate({ pathname: `/${UserMfaFlow.MfaVerification}` }, { replace, state });
|
||||
return;
|
||||
}
|
||||
|
||||
const factor = availableFactors[0];
|
||||
if (!factor) {
|
||||
setToast(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (factor === MfaFactor.TOTP) {
|
||||
const state: TotpVerificationState = { allowOtherFactors: false };
|
||||
navigate({ pathname: `/${UserMfaFlow.MfaVerification}/${factor}` }, { replace, state });
|
||||
}
|
||||
// Todo: @xiaoyijun handle other factors
|
||||
},
|
||||
}),
|
||||
[navigate, replace, setToast, startBindingTotp]
|
||||
);
|
||||
|
||||
return mfaVerificationErrorHandler;
|
||||
};
|
||||
|
||||
export default useMfaVerificationErrorHandler;
|
|
@ -6,7 +6,7 @@ import useApi from '@/hooks/use-api';
|
|||
import type { ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
|
||||
import useRequiredProfileErrorHandler from './use-required-profile-error-handler';
|
||||
import usePreSignInErrorHandler from './use-pre-sign-in-error-handler';
|
||||
|
||||
const usePasswordSignIn = () => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
|
@ -17,17 +17,16 @@ const usePasswordSignIn = () => {
|
|||
|
||||
const handleError = useErrorHandler();
|
||||
const asyncSignIn = useApi(signInWithPasswordIdentifier);
|
||||
|
||||
const requiredProfileErrorHandler = useRequiredProfileErrorHandler();
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler();
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'session.invalid_credentials': (error) => {
|
||||
setErrorMessage(error.message);
|
||||
},
|
||||
...requiredProfileErrorHandler,
|
||||
...preSignInErrorHandler,
|
||||
}),
|
||||
[requiredProfileErrorHandler]
|
||||
[preSignInErrorHandler]
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { isDevelopmentFeaturesEnabled } from '@/constants/env';
|
||||
|
||||
import { type ErrorHandlers } from './use-error-handler';
|
||||
import useMfaVerificationErrorHandler, {
|
||||
type Options as UseMfaVerificationErrorHandlerOptions,
|
||||
} from './use-mfa-verification-error-handler';
|
||||
import useRequiredProfileErrorHandler, {
|
||||
type Options as UseRequiredProfileErrorHandlerOptions,
|
||||
} from './use-required-profile-error-handler';
|
||||
|
||||
type Options = UseRequiredProfileErrorHandlerOptions & UseMfaVerificationErrorHandlerOptions;
|
||||
|
||||
const usePreSignInErrorHandler = ({ replace, linkSocial }: Options = {}): ErrorHandlers => {
|
||||
const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ replace, linkSocial });
|
||||
const mfaVerificationErrorHandler = useMfaVerificationErrorHandler({ replace });
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
...requiredProfileErrorHandler,
|
||||
...conditional(isDevelopmentFeaturesEnabled && mfaVerificationErrorHandler),
|
||||
}),
|
||||
[mfaVerificationErrorHandler, requiredProfileErrorHandler]
|
||||
);
|
||||
};
|
||||
|
||||
export default usePreSignInErrorHandler;
|
|
@ -10,7 +10,7 @@ import { queryStringify } from '@/utils';
|
|||
import type { ErrorHandlers } from './use-error-handler';
|
||||
import useToast from './use-toast';
|
||||
|
||||
type Options = {
|
||||
export type Options = {
|
||||
replace?: boolean;
|
||||
linkSocial?: string;
|
||||
};
|
||||
|
|
|
@ -4,11 +4,11 @@ import { bindSocialRelatedUser } from '@/apis/interaction';
|
|||
|
||||
import useApi from './use-api';
|
||||
import useErrorHandler from './use-error-handler';
|
||||
import useRequiredProfileErrorHandler from './use-required-profile-error-handler';
|
||||
import usePreSignInErrorHandler from './use-pre-sign-in-error-handler';
|
||||
|
||||
const useBindSocialRelatedUser = () => {
|
||||
const handleError = useErrorHandler();
|
||||
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler();
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler();
|
||||
|
||||
const asyncBindSocialRelatedUser = useApi(bindSocialRelatedUser);
|
||||
|
||||
|
@ -17,7 +17,7 @@ const useBindSocialRelatedUser = () => {
|
|||
const [error, result] = await asyncBindSocialRelatedUser(...payload);
|
||||
|
||||
if (error) {
|
||||
await handleError(error, requiredProfileErrorHandlers);
|
||||
await handleError(error, preSignInErrorHandler);
|
||||
|
||||
return;
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ const useBindSocialRelatedUser = () => {
|
|||
window.location.replace(result.redirectTo);
|
||||
}
|
||||
},
|
||||
[asyncBindSocialRelatedUser, handleError, requiredProfileErrorHandlers]
|
||||
[asyncBindSocialRelatedUser, handleError, preSignInErrorHandler]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -4,23 +4,20 @@ import { registerWithVerifiedSocial } from '@/apis/interaction';
|
|||
|
||||
import useApi from './use-api';
|
||||
import useErrorHandler from './use-error-handler';
|
||||
import useRequiredProfileErrorHandler from './use-required-profile-error-handler';
|
||||
import usePreSignInErrorHandler from './use-pre-sign-in-error-handler';
|
||||
|
||||
const useSocialRegister = (connectorId?: string, replace?: boolean) => {
|
||||
const handleError = useErrorHandler();
|
||||
const asyncRegisterWithSocial = useApi(registerWithVerifiedSocial);
|
||||
|
||||
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler({
|
||||
linkSocial: connectorId,
|
||||
replace,
|
||||
});
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ linkSocial: connectorId, replace });
|
||||
|
||||
return useCallback(
|
||||
async (connectorId: string) => {
|
||||
const [error, result] = await asyncRegisterWithSocial(connectorId);
|
||||
|
||||
if (error) {
|
||||
await handleError(error, requiredProfileErrorHandlers);
|
||||
await handleError(error, preSignInErrorHandler);
|
||||
|
||||
return;
|
||||
}
|
||||
|
@ -29,7 +26,7 @@ const useSocialRegister = (connectorId?: string, replace?: boolean) => {
|
|||
window.location.replace(result.redirectTo);
|
||||
}
|
||||
},
|
||||
[asyncRegisterWithSocial, handleError, requiredProfileErrorHandlers]
|
||||
[asyncRegisterWithSocial, handleError, preSignInErrorHandler]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ import { stateValidation } from '@/utils/social-connectors';
|
|||
import useApi from './use-api';
|
||||
import useErrorHandler from './use-error-handler';
|
||||
import type { ErrorHandlers } from './use-error-handler';
|
||||
import useRequiredProfileErrorHandler from './use-required-profile-error-handler';
|
||||
import usePreSignInErrorHandler from './use-pre-sign-in-error-handler';
|
||||
import { useSieMethods } from './use-sie';
|
||||
import useSocialRegister from './use-social-register';
|
||||
import useTerms from './use-terms';
|
||||
|
@ -58,9 +58,8 @@ const useSocialSignInListener = (connectorId?: string) => {
|
|||
},
|
||||
[connectorId, navigate, registerWithSocial]
|
||||
);
|
||||
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler({
|
||||
replace: true,
|
||||
});
|
||||
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
|
||||
|
||||
const signInWithSocialErrorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
|
@ -79,15 +78,9 @@ const useSocialSignInListener = (connectorId?: string) => {
|
|||
|
||||
await accountNotExistErrorHandler(error);
|
||||
},
|
||||
...requiredProfileErrorHandlers,
|
||||
...preSignInErrorHandler,
|
||||
}),
|
||||
[
|
||||
requiredProfileErrorHandlers,
|
||||
signInMode,
|
||||
termsValidation,
|
||||
accountNotExistErrorHandler,
|
||||
setToast,
|
||||
]
|
||||
[preSignInErrorHandler, signInMode, termsValidation, accountNotExistErrorHandler, setToast]
|
||||
);
|
||||
|
||||
const signInWithSocialHandler = useCallback(
|
||||
|
|
47
packages/experience/src/hooks/use-start-binding-totp.ts
Normal file
47
packages/experience/src/hooks/use-start-binding-totp.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
import qrcode from 'qrcode';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { createTotpSecret } from '@/apis/interaction';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { type TotpBindingState } from '@/types/guard';
|
||||
|
||||
type Options = {
|
||||
replace?: boolean;
|
||||
};
|
||||
|
||||
const useStartTotpBinding = ({ replace }: Options = {}) => {
|
||||
const navigate = useNavigate();
|
||||
const asyncCreateTotpSecret = useApi(createTotpSecret);
|
||||
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
return useCallback(
|
||||
async (allowOtherFactors = false) => {
|
||||
const [error, result] = await asyncCreateTotpSecret();
|
||||
|
||||
if (error) {
|
||||
await handleError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const { secret } = result ?? {};
|
||||
|
||||
if (secret) {
|
||||
const state: TotpBindingState = {
|
||||
secret,
|
||||
// Todo @wangsijie generate QR code on the server side
|
||||
secretQrCode: await qrcode.toDataURL(`otpauth://totp/?secret=${secret}`),
|
||||
allowOtherFactors,
|
||||
};
|
||||
navigate({ pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.TOTP}` }, { replace, state });
|
||||
}
|
||||
},
|
||||
[asyncCreateTotpSecret, handleError, navigate, replace]
|
||||
);
|
||||
};
|
||||
|
||||
export default useStartTotpBinding;
|
23
packages/experience/src/hooks/use-text-handler.ts
Normal file
23
packages/experience/src/hooks/use-text-handler.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import useToast from './use-toast';
|
||||
|
||||
const useTextHandler = () => {
|
||||
const { setToast } = useToast();
|
||||
|
||||
const copyText = useCallback(
|
||||
async (text: string, successMessage: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setToast(successMessage);
|
||||
},
|
||||
[setToast]
|
||||
);
|
||||
|
||||
// Todo: @xiaoyijun add download text file handler
|
||||
|
||||
return {
|
||||
copyText,
|
||||
};
|
||||
};
|
||||
|
||||
export default useTextHandler;
|
|
@ -7,7 +7,7 @@ import SetPasswordForm from '@/containers/SetPassword';
|
|||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import type { ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import usePasswordAction, { type SuccessHandler } from '@/hooks/use-password-action';
|
||||
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
|
||||
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
|
||||
import { usePasswordPolicy } from '@/hooks/use-sie';
|
||||
|
||||
const SetPassword = () => {
|
||||
|
@ -19,16 +19,17 @@ const SetPassword = () => {
|
|||
const navigate = useNavigate();
|
||||
const { show } = useConfirmModal();
|
||||
|
||||
const requiredProfileErrorHandler = useRequiredProfileErrorHandler();
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler();
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'user.password_exists_in_profile': async (error) => {
|
||||
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
|
||||
navigate(-1);
|
||||
},
|
||||
...requiredProfileErrorHandler,
|
||||
...preSignInErrorHandler,
|
||||
}),
|
||||
[navigate, requiredProfileErrorHandler, show]
|
||||
[navigate, preSignInErrorHandler, show]
|
||||
);
|
||||
const successHandler: SuccessHandler<typeof addProfile> = useCallback((result) => {
|
||||
if (result?.redirectTo) {
|
||||
|
|
|
@ -4,7 +4,7 @@ import { addProfile } from '@/apis/interaction';
|
|||
import useApi from '@/hooks/use-api';
|
||||
import type { ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
|
||||
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
|
||||
|
||||
const useSetUsername = () => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
|
@ -15,16 +15,17 @@ const useSetUsername = () => {
|
|||
|
||||
const asyncAddProfile = useApi(addProfile);
|
||||
const handleError = useErrorHandler();
|
||||
const requiredProfileErrorHandler = useRequiredProfileErrorHandler();
|
||||
|
||||
const preSignInErrorHandler = usePreSignInErrorHandler();
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'user.username_already_in_use': (error) => {
|
||||
setErrorMessage(error.message);
|
||||
},
|
||||
...requiredProfileErrorHandler,
|
||||
...preSignInErrorHandler,
|
||||
}),
|
||||
[requiredProfileErrorHandler]
|
||||
[preSignInErrorHandler]
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.secretContent {
|
||||
@include _.flex-column(center);
|
||||
|
||||
.qrCode {
|
||||
border: 1px solid var(--color-line-divider);
|
||||
margin: _.unit(4);
|
||||
height: 136px;
|
||||
width: 136px;
|
||||
|
||||
> img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
@include _.image-align-center;
|
||||
}
|
||||
}
|
||||
|
||||
.rawSecret {
|
||||
padding: _.unit(4);
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font: var(--font-label-1);
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--color-bg-layer-2);
|
||||
color: var(--color-type-primary);
|
||||
margin-bottom: _.unit(3);
|
||||
}
|
||||
|
||||
.copySecret {
|
||||
width: 100%;
|
||||
margin: _.unit(4);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SectionLayout from '@/Layout/SectionLayout';
|
||||
import Button from '@/components/Button';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import useTextHandler from '@/hooks/use-text-handler';
|
||||
import { type TotpBindingState } from '@/types/guard';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const SecretSection = ({ secret, secretQrCode }: TotpBindingState) => {
|
||||
const { t } = useTranslation();
|
||||
const [isQrCodeFormat, setIsQrCodeFormat] = useState(true);
|
||||
const { copyText } = useTextHandler();
|
||||
|
||||
return (
|
||||
<SectionLayout
|
||||
title="mfa.step"
|
||||
titleProps={{
|
||||
step: 1,
|
||||
content: t(isQrCodeFormat ? 'mfa.scan_qr_code' : 'mfa.copy_and_paste_key'),
|
||||
}}
|
||||
description={
|
||||
isQrCodeFormat ? 'mfa.scan_qr_code_description' : 'mfa.copy_and_paste_key_description'
|
||||
}
|
||||
>
|
||||
<div className={styles.secretContent}>
|
||||
{isQrCodeFormat && secretQrCode && (
|
||||
<div className={styles.qrCode}>
|
||||
<img src={secretQrCode} alt="QR code" />
|
||||
</div>
|
||||
)}
|
||||
{!isQrCodeFormat && (
|
||||
<div className={styles.copySecret}>
|
||||
<div className={styles.rawSecret}>{secret}</div>
|
||||
<Button
|
||||
title="action.copy"
|
||||
type="secondary"
|
||||
onClick={() => {
|
||||
void copyText(secret, t('mfa.secret_key_copied'));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<TextLink
|
||||
text={isQrCodeFormat ? 'mfa.qr_code_not_available' : 'mfa.want_to_scan_qr_code'}
|
||||
onClick={() => {
|
||||
setIsQrCodeFormat(!isQrCodeFormat);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SectionLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecretSection;
|
|
@ -0,0 +1,24 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SectionLayout from '@/Layout/SectionLayout';
|
||||
import TotpCodeVerification from '@/containers/TotpCodeVerification';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
|
||||
const VerificationSection = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SectionLayout
|
||||
title="mfa.step"
|
||||
titleProps={{
|
||||
step: 2,
|
||||
content: t('mfa.enter_one_time_code'),
|
||||
}}
|
||||
description="mfa.enter_one_time_code_link_description"
|
||||
>
|
||||
<TotpCodeVerification flow={UserMfaFlow.MfaBinding} />
|
||||
</SectionLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationSection;
|
|
@ -0,0 +1,8 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
@include _.flex-column;
|
||||
gap: _.unit(6);
|
||||
margin-bottom: _.unit(6);
|
||||
align-items: stretch;
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import SwitchIcon from '@/assets/icons/switch-icon.svg';
|
||||
import Divider from '@/components/Divider';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import ErrorPage from '@/pages/ErrorPage';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { totpBindingStateGuard } from '@/types/guard';
|
||||
|
||||
import SecretSection from './SecretSection';
|
||||
import VerificationSection from './VerificationSection';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const TotpBinding = () => {
|
||||
const { state } = useLocation();
|
||||
const [, totpBindingState] = validate(state, totpBindingStateGuard);
|
||||
|
||||
if (!totpBindingState) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout title="mfa.add_authenticator_app">
|
||||
<div className={styles.container}>
|
||||
<SecretSection {...totpBindingState} />
|
||||
<Divider />
|
||||
<VerificationSection />
|
||||
{totpBindingState.allowOtherFactors && (
|
||||
<>
|
||||
<Divider />
|
||||
<TextLink
|
||||
to={`/${UserMfaFlow.MfaBinding}`}
|
||||
text="mfa.link_another_mfa_factor"
|
||||
icon={<SwitchIcon />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SecondaryPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TotpBinding;
|
27
packages/experience/src/pages/MfaBinding/index.tsx
Normal file
27
packages/experience/src/pages/MfaBinding/index.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import MfaFactorList from '@/containers/MfaFactorList';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { mfaFactorsStateGuard } from '@/types/guard';
|
||||
|
||||
import ErrorPage from '../ErrorPage';
|
||||
|
||||
const MfaBinding = () => {
|
||||
const { state } = useLocation();
|
||||
const [, mfaFactorsState] = validate(state, mfaFactorsStateGuard);
|
||||
const { factors } = mfaFactorsState ?? {};
|
||||
|
||||
if (!factors || factors.length === 0) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout title="mfa.add_mfa_factors" description="mfa.add_mfa_description">
|
||||
<MfaFactorList flow={UserMfaFlow.MfaBinding} factors={factors} />
|
||||
</SecondaryPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MfaBinding;
|
|
@ -0,0 +1,5 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.switchFactorLink {
|
||||
margin-top: _.unit(6);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import SectionLayout from '@/Layout/SectionLayout';
|
||||
import SwitchIcon from '@/assets/icons/switch-icon.svg';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import TotpCodeVerification from '@/containers/TotpCodeVerification';
|
||||
import ErrorPage from '@/pages/ErrorPage';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { totpVerificationStateGuard } from '@/types/guard';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const TotpVerification = () => {
|
||||
const { state } = useLocation();
|
||||
const [, totpVerificationState] = validate(state, totpVerificationStateGuard);
|
||||
|
||||
if (!totpVerificationState) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout title="mfa.verify_mfa_factors">
|
||||
<SectionLayout
|
||||
title="mfa.enter_one_time_code"
|
||||
description="mfa.enter_one_time_code_description"
|
||||
>
|
||||
<TotpCodeVerification flow={UserMfaFlow.MfaVerification} />
|
||||
</SectionLayout>
|
||||
{totpVerificationState.allowOtherFactors && (
|
||||
<TextLink
|
||||
to={`/${UserMfaFlow.MfaVerification}`}
|
||||
text="mfa.try_another_verification_method"
|
||||
icon={<SwitchIcon />}
|
||||
className={styles.switchFactorLink}
|
||||
/>
|
||||
)}
|
||||
</SecondaryPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TotpVerification;
|
27
packages/experience/src/pages/MfaVerification/index.tsx
Normal file
27
packages/experience/src/pages/MfaVerification/index.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import MfaFactorList from '@/containers/MfaFactorList';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { mfaFactorsStateGuard } from '@/types/guard';
|
||||
|
||||
import ErrorPage from '../ErrorPage';
|
||||
|
||||
const MfaVerification = () => {
|
||||
const { state } = useLocation();
|
||||
const [, mfaFactorsState] = validate(state, mfaFactorsStateGuard);
|
||||
const { factors } = mfaFactorsState ?? {};
|
||||
|
||||
if (!factors || factors.length === 0) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout title="mfa.verify_mfa_factors" description="mfa.verify_mfa_description">
|
||||
<MfaFactorList flow={UserMfaFlow.MfaVerification} factors={factors} />
|
||||
</SecondaryPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MfaVerification;
|
|
@ -7,6 +7,7 @@ import { setUserPassword } from '@/apis/interaction';
|
|||
import SetPassword from '@/containers/SetPassword';
|
||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import { type ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import useMfaVerificationErrorHandler from '@/hooks/use-mfa-verification-error-handler';
|
||||
import usePasswordAction, { type SuccessHandler } from '@/hooks/use-password-action';
|
||||
import { usePasswordPolicy, useSieMethods } from '@/hooks/use-sie';
|
||||
|
||||
|
@ -21,6 +22,9 @@ const RegisterPassword = () => {
|
|||
const clearErrorMessage = useCallback(() => {
|
||||
setErrorMessage(undefined);
|
||||
}, []);
|
||||
|
||||
const mfaVerificationErrorHandler = useMfaVerificationErrorHandler({ replace: true });
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
// Incase previous page submitted username has been taken
|
||||
|
@ -28,9 +32,11 @@ const RegisterPassword = () => {
|
|||
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
|
||||
navigate(-1);
|
||||
},
|
||||
...mfaVerificationErrorHandler,
|
||||
}),
|
||||
[navigate, show]
|
||||
[navigate, mfaVerificationErrorHandler, show]
|
||||
);
|
||||
|
||||
const successHandler: SuccessHandler<typeof setUserPassword> = useCallback((result) => {
|
||||
if (result && 'redirectTo' in result) {
|
||||
window.location.replace(result.redirectTo);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SignInIdentifier, MissingProfile } from '@logto/schemas';
|
||||
import { SignInIdentifier, MissingProfile, MfaFactor } from '@logto/schemas';
|
||||
import * as s from 'superstruct';
|
||||
|
||||
import { UserFlow } from '.';
|
||||
|
@ -61,3 +61,44 @@ export const socialAccountNotExistErrorDataGuard = s.object({
|
|||
export type SocialRelatedUserInfo = s.Infer<
|
||||
typeof socialAccountNotExistErrorDataGuard
|
||||
>['relatedUser'];
|
||||
|
||||
/* Mfa */
|
||||
const mfaFactorsGuard = s.array(
|
||||
s.union([
|
||||
s.literal(MfaFactor.TOTP),
|
||||
s.literal(MfaFactor.WebAuthn),
|
||||
s.literal(MfaFactor.BackupCode),
|
||||
])
|
||||
);
|
||||
|
||||
export const missingMfaFactorsErrorDataGuard = s.object({
|
||||
missingFactors: mfaFactorsGuard,
|
||||
});
|
||||
|
||||
export const requireMfaFactorsErrorDataGuard = s.object({
|
||||
availableFactors: mfaFactorsGuard,
|
||||
});
|
||||
|
||||
export const mfaFactorsStateGuard = s.object({
|
||||
factors: mfaFactorsGuard,
|
||||
});
|
||||
|
||||
export type MfaFactorsState = s.Infer<typeof mfaFactorsStateGuard>;
|
||||
|
||||
const mfaFlowStateGuard = s.object({
|
||||
allowOtherFactors: s.boolean(),
|
||||
});
|
||||
|
||||
export const totpBindingStateGuard = s.assign(
|
||||
s.object({
|
||||
secret: s.string(),
|
||||
secretQrCode: s.string(),
|
||||
}),
|
||||
mfaFlowStateGuard
|
||||
);
|
||||
|
||||
export type TotpBindingState = s.Infer<typeof totpBindingStateGuard>;
|
||||
|
||||
export const totpVerificationStateGuard = mfaFlowStateGuard;
|
||||
|
||||
export type TotpVerificationState = s.Infer<typeof totpVerificationStateGuard>;
|
||||
|
|
|
@ -7,6 +7,11 @@ export enum UserFlow {
|
|||
Continue = 'continue',
|
||||
}
|
||||
|
||||
export enum UserMfaFlow {
|
||||
MfaBinding = 'mfa-binding',
|
||||
MfaVerification = 'mfa-verification',
|
||||
}
|
||||
|
||||
export enum SearchParameters {
|
||||
NativeCallbackLink = 'native_callback',
|
||||
RedirectTo = 'redirect_to',
|
||||
|
|
52
pnpm-lock.yaml
generated
52
pnpm-lock.yaml
generated
|
@ -3472,6 +3472,10 @@ importers:
|
|||
version: 3.20.2
|
||||
|
||||
packages/experience:
|
||||
dependencies:
|
||||
qrcode:
|
||||
specifier: ^1.5.3
|
||||
version: 1.5.3
|
||||
devDependencies:
|
||||
'@jest/types':
|
||||
specifier: ^29.5.0
|
||||
|
@ -3548,6 +3552,9 @@ importers:
|
|||
'@types/jest':
|
||||
specifier: ^29.4.0
|
||||
version: 29.4.0
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.2
|
||||
version: 1.5.2
|
||||
'@types/react':
|
||||
specifier: ^18.0.31
|
||||
version: 18.0.31
|
||||
|
@ -9759,6 +9766,12 @@ packages:
|
|||
resolution: {integrity: sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==}
|
||||
dev: true
|
||||
|
||||
/@types/qrcode@1.5.2:
|
||||
resolution: {integrity: sha512-W4KDz75m7rJjFbyCctzCtRzZUj+PrUHV+YjqDp50sSRezTbrtEAIq2iTzC6lISARl3qw+8IlcCyljdcVJE0Wug==}
|
||||
dependencies:
|
||||
'@types/node': 18.11.18
|
||||
dev: true
|
||||
|
||||
/@types/qs@6.9.7:
|
||||
resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==}
|
||||
dev: true
|
||||
|
@ -10790,7 +10803,6 @@ packages:
|
|||
/camelcase@5.3.1:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/camelcase@6.3.0:
|
||||
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
|
||||
|
@ -10963,7 +10975,6 @@ packages:
|
|||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
dev: true
|
||||
|
||||
/cliui@7.0.4:
|
||||
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
|
||||
|
@ -11565,7 +11576,6 @@ packages:
|
|||
/decamelize@1.2.0:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/decamelize@5.0.1:
|
||||
resolution: {integrity: sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==}
|
||||
|
@ -11758,6 +11768,10 @@ packages:
|
|||
engines: {node: '>=0.3.1'}
|
||||
dev: true
|
||||
|
||||
/dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
dev: false
|
||||
|
||||
/dir-glob@3.0.1:
|
||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||
engines: {node: '>=8'}
|
||||
|
@ -11903,6 +11917,10 @@ packages:
|
|||
/emoji-regex@9.2.2:
|
||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||
|
||||
/encode-utf8@1.0.3:
|
||||
resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==}
|
||||
dev: false
|
||||
|
||||
/encodeurl@1.0.2:
|
||||
resolution: {integrity: sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
@ -12835,7 +12853,6 @@ packages:
|
|||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
dev: true
|
||||
|
||||
/find-up@5.0.0:
|
||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||
|
@ -15348,7 +15365,6 @@ packages:
|
|||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
dev: true
|
||||
|
||||
/locate-path@6.0.0:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
|
@ -16752,7 +16768,6 @@ packages:
|
|||
engines: {node: '>=6'}
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
dev: true
|
||||
|
||||
/p-limit@3.1.0:
|
||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||
|
@ -16773,7 +16788,6 @@ packages:
|
|||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
p-limit: 2.3.0
|
||||
dev: true
|
||||
|
||||
/p-locate@5.0.0:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
|
@ -16818,7 +16832,6 @@ packages:
|
|||
/p-try@2.2.0:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/pac-proxy-agent@7.0.0:
|
||||
resolution: {integrity: sha512-t4tRAMx0uphnZrio0S0Jw9zg3oDbz1zVhQ/Vy18FjLfP1XOLNUEjaVxYCYRI6NS+BsMBXKIzV6cTLOkO9AtywA==}
|
||||
|
@ -16984,7 +16997,6 @@ packages:
|
|||
/path-exists@4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
dev: true
|
||||
|
||||
/path-exists@5.0.0:
|
||||
resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==}
|
||||
|
@ -17189,6 +17201,11 @@ packages:
|
|||
engines: {node: '>=4'}
|
||||
dev: true
|
||||
|
||||
/pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
dev: false
|
||||
|
||||
/postcss-media-query-parser@0.2.3:
|
||||
resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==}
|
||||
dev: true
|
||||
|
@ -17662,6 +17679,17 @@ packages:
|
|||
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
|
||||
dev: true
|
||||
|
||||
/qrcode@1.5.3:
|
||||
resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
encode-utf8: 1.0.3
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
dev: false
|
||||
|
||||
/qs@6.10.3:
|
||||
resolution: {integrity: sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
@ -18380,7 +18408,6 @@ packages:
|
|||
|
||||
/require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
dev: true
|
||||
|
||||
/requires-port@1.0.0:
|
||||
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
|
||||
|
@ -18675,7 +18702,6 @@ packages:
|
|||
|
||||
/set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
dev: true
|
||||
|
||||
/setprototypeof@1.1.0:
|
||||
resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==}
|
||||
|
@ -20463,7 +20489,6 @@ packages:
|
|||
|
||||
/which-module@2.0.0:
|
||||
resolution: {integrity: sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==}
|
||||
dev: true
|
||||
|
||||
/which-pm@2.0.0:
|
||||
resolution: {integrity: sha512-Lhs9Pmyph0p5n5Z3mVnN0yWcbQYUAD7rbQUiMsQxOJ3T57k7RFe35SUwWMf7dsbDZks1uOmw4AecB/JMDj3v/w==}
|
||||
|
@ -20616,7 +20641,6 @@ packages:
|
|||
|
||||
/y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
dev: true
|
||||
|
||||
/y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
|
@ -20644,7 +20668,6 @@ packages:
|
|||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
decamelize: 1.2.0
|
||||
dev: true
|
||||
|
||||
/yargs-parser@20.2.9:
|
||||
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
|
||||
|
@ -20670,7 +20693,6 @@ packages:
|
|||
which-module: 2.0.0
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
dev: true
|
||||
|
||||
/yargs@16.2.0:
|
||||
resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
|
||||
|
|
Loading…
Add table
Reference in a new issue