mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
feat(ui): load sign-in experience settings from server (#542)
* feat(ui): load sign-in experience from server load sign-in experience settings from server * feat(ui): return null if no setting find return null if no setting found * chore(ui): add todo add todo * fix(ui): remove console log remove console log * fix(ui): cr fix cr fix
This commit is contained in:
parent
f0a961225d
commit
99e425496f
19 changed files with 243 additions and 86 deletions
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Route, Routes, BrowserRouter } from 'react-router-dom';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { Route, Routes, BrowserRouter, Navigate } from 'react-router-dom';
|
||||
|
||||
import AppContent from './components/AppContent';
|
||||
import useTheme from './hooks/use-theme';
|
||||
import PageContext from './hooks/page-context';
|
||||
import initI18n from './i18n/init';
|
||||
import Callback from './pages/Callback';
|
||||
import Consent from './pages/Consent';
|
||||
|
@ -10,28 +10,55 @@ import Passcode from './pages/Passcode';
|
|||
import Register from './pages/Register';
|
||||
import SecondarySignIn from './pages/SecondarySignIn';
|
||||
import SignIn from './pages/SignIn';
|
||||
import { SignInExperienceSettings } from './types';
|
||||
import getSignInExperienceSettings from './utils/sign-in-experience';
|
||||
|
||||
import './scss/normalized.scss';
|
||||
|
||||
void initI18n();
|
||||
|
||||
const App = () => {
|
||||
const theme = useTheme();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toast, setToast] = useState('');
|
||||
const [experienceSettings, setExperienceSettings] = useState<SignInExperienceSettings>();
|
||||
|
||||
const context = useMemo(
|
||||
() => ({ toast, loading, experienceSettings, setLoading, setToast, setExperienceSettings }),
|
||||
[experienceSettings, loading, toast]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
// TODO: error handling
|
||||
const { settings } = await getSignInExperienceSettings();
|
||||
setExperienceSettings(settings);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (!experienceSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContent theme={theme}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* always keep route path with param as the last one */}
|
||||
<Route path="/sign-in" element={<SignIn />} />
|
||||
<Route path="/sign-in/consent" element={<Consent />} />
|
||||
<Route path="/sign-in/:channel" element={<SecondarySignIn />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/register/:channel" element={<Register />} />
|
||||
<Route path="/:type/:channel/passcode-validation" element={<Passcode />} />
|
||||
<Route path="/callback/:connector" element={<Callback />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AppContent>
|
||||
<PageContext.Provider value={context}>
|
||||
<AppContent>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* always keep route path with param as the last one */}
|
||||
<Route path="/" element={<Navigate replace to="/sign-in" />} />
|
||||
<Route path="/sign-in" element={<SignIn />} />
|
||||
<Route path="/sign-in/consent" element={<Consent />} />
|
||||
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/register/:method" element={<Register />} />
|
||||
<Route path="/:type/:method/passcode-validation" element={<Passcode />} />
|
||||
<Route path="/callback/:connector" element={<Callback />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AppContent>
|
||||
</PageContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -27,3 +27,30 @@ export const socialConnectors = [
|
|||
name: 'Meta',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockSignInExperience = {
|
||||
id: 'foo',
|
||||
branding: {
|
||||
primaryColor: '#000',
|
||||
isDarkModeEnabled: true,
|
||||
darkPrimaryColor: '#fff',
|
||||
style: 'Logo_Slogan',
|
||||
logoUrl: 'http://logto.png',
|
||||
slogan: 'logto',
|
||||
},
|
||||
termsOfUse: {
|
||||
enabled: false,
|
||||
},
|
||||
languageInfo: {
|
||||
autoDetect: true,
|
||||
fallbackLanguage: 'en',
|
||||
fixedLanguage: 'zh-cn',
|
||||
},
|
||||
signInMethods: {
|
||||
username: 'primary',
|
||||
email: 'secondary',
|
||||
sms: 'secondary',
|
||||
social: 'secondary',
|
||||
},
|
||||
socialSignInConnectorIds: ['github', 'facebook'],
|
||||
};
|
||||
|
|
11
packages/ui/src/apis/settings.ts
Normal file
11
packages/ui/src/apis/settings.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Used to get and general sign-in experience settings.
|
||||
* The API will be depreated in the future once SSR is implemented.
|
||||
*/
|
||||
|
||||
import { SignInExperience } from '@logto/schemas';
|
||||
import ky from 'ky';
|
||||
|
||||
export const getSignInExperience = async () => {
|
||||
return ky.get('/api/sign-in-settings').json<SignInExperience>();
|
||||
};
|
|
@ -15,32 +15,32 @@ import {
|
|||
|
||||
export type PasscodeChannel = 'sms' | 'email';
|
||||
|
||||
export const getSendPasscodeApi = (type: UserFlow, channel: PasscodeChannel) => {
|
||||
if (type === 'sign-in' && channel === 'email') {
|
||||
export const getSendPasscodeApi = (type: UserFlow, method: PasscodeChannel) => {
|
||||
if (type === 'sign-in' && method === 'email') {
|
||||
return sendSignInEmailPasscode;
|
||||
}
|
||||
|
||||
if (type === 'sign-in' && channel === 'sms') {
|
||||
if (type === 'sign-in' && method === 'sms') {
|
||||
return sendSignInSmsPasscode;
|
||||
}
|
||||
|
||||
if (type === 'register' && channel === 'email') {
|
||||
if (type === 'register' && method === 'email') {
|
||||
return sendRegisterEmailPasscode;
|
||||
}
|
||||
|
||||
return sendRegisterSmsPasscode;
|
||||
};
|
||||
|
||||
export const getVerifyPasscodeApi = (type: UserFlow, channel: PasscodeChannel) => {
|
||||
if (type === 'sign-in' && channel === 'email') {
|
||||
export const getVerifyPasscodeApi = (type: UserFlow, method: PasscodeChannel) => {
|
||||
if (type === 'sign-in' && method === 'email') {
|
||||
return verifySignInEmailPasscode;
|
||||
}
|
||||
|
||||
if (type === 'sign-in' && channel === 'sms') {
|
||||
if (type === 'sign-in' && method === 'sms') {
|
||||
return verifySignInSmsPasscode;
|
||||
}
|
||||
|
||||
if (type === 'register' && channel === 'email') {
|
||||
if (type === 'register' && method === 'email') {
|
||||
return verifyRegisterEmailPasscode;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,38 +1,32 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { ReactNode, useState, useMemo, useCallback } from 'react';
|
||||
import React, { ReactNode, useCallback, useContext } from 'react';
|
||||
|
||||
import LoadingLayer from '@/components/LoadingLayer';
|
||||
import Toast from '@/components/Toast';
|
||||
import PageContext from '@/hooks/page-context';
|
||||
import useTheme from '@/hooks/use-theme';
|
||||
|
||||
import LoadingLayer from '../LoadingLayer';
|
||||
import Toast from '../Toast';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Theme = 'dark' | 'light';
|
||||
|
||||
export type Props = {
|
||||
theme: Theme;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const AppContent = ({ children, theme }: Props) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
const context = useMemo(() => ({ toast, loading, setLoading, setToast }), [loading, toast]);
|
||||
const AppContent = ({ children }: Props) => {
|
||||
const theme = useTheme();
|
||||
const { toast, loading, setToast } = useContext(PageContext);
|
||||
|
||||
// Prevent internal eventListener rebind
|
||||
const hideToast = useCallback(() => {
|
||||
setToast('');
|
||||
}, []);
|
||||
}, [setToast]);
|
||||
|
||||
return (
|
||||
<PageContext.Provider value={context}>
|
||||
<main className={classNames(styles.content, styles.universal, styles.mobile, styles[theme])}>
|
||||
{children}
|
||||
<Toast message={toast} isVisible={Boolean(toast)} callback={hideToast} />
|
||||
{loading && <LoadingLayer />}
|
||||
</main>
|
||||
</PageContext.Provider>
|
||||
<main className={classNames(styles.content, styles.universal, styles.mobile, styles[theme])}>
|
||||
{children}
|
||||
<Toast message={toast} isVisible={Boolean(toast)} callback={hideToast} />
|
||||
{loading && <LoadingLayer />}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -29,7 +29,7 @@ describe('<PasscodeValidation />', () => {
|
|||
|
||||
it('render counter', () => {
|
||||
const { queryByText } = render(
|
||||
<PasscodeValidation type="sign-in" channel="email" target={email} />
|
||||
<PasscodeValidation type="sign-in" method="email" target={email} />
|
||||
);
|
||||
|
||||
expect(queryByText('description.resend_after_senconds')).not.toBeNull();
|
||||
|
@ -43,7 +43,7 @@ describe('<PasscodeValidation />', () => {
|
|||
|
||||
it('fire resend event', async () => {
|
||||
const { getByText } = render(
|
||||
<PasscodeValidation type="sign-in" channel="email" target={email} />
|
||||
<PasscodeValidation type="sign-in" method="email" target={email} />
|
||||
);
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
|
@ -59,7 +59,7 @@ describe('<PasscodeValidation />', () => {
|
|||
|
||||
it('fire validate passcode event', async () => {
|
||||
const { container } = render(
|
||||
<PasscodeValidation type="sign-in" channel="email" target={email} />
|
||||
<PasscodeValidation type="sign-in" method="email" target={email} />
|
||||
);
|
||||
const inputs = container.querySelectorAll('input');
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ import * as styles from './index.module.scss';
|
|||
|
||||
type Props = {
|
||||
type: UserFlow;
|
||||
channel: 'email' | 'sms';
|
||||
method: 'email' | 'sms';
|
||||
target: string;
|
||||
className?: string;
|
||||
};
|
||||
|
@ -30,7 +30,7 @@ const getTimeout = () => {
|
|||
return now;
|
||||
};
|
||||
|
||||
const PasscodeValidation = ({ type, channel, className, target }: Props) => {
|
||||
const PasscodeValidation = ({ type, method, className, target }: Props) => {
|
||||
const [code, setCode] = useState<string[]>([]);
|
||||
const [error, setError] = useState<ErrorType>();
|
||||
const { setToast } = useContext(PageContext);
|
||||
|
@ -45,13 +45,13 @@ const PasscodeValidation = ({ type, channel, className, target }: Props) => {
|
|||
error: verifyPasscodeError,
|
||||
result: verifyPasscodeResult,
|
||||
run: verifyPassCode,
|
||||
} = useApi(getVerifyPasscodeApi(type, channel));
|
||||
} = useApi(getVerifyPasscodeApi(type, method));
|
||||
|
||||
const {
|
||||
error: sendPasscodeError,
|
||||
result: sendPasscodeResult,
|
||||
run: sendPassCode,
|
||||
} = useApi(getSendPasscodeApi(type, channel));
|
||||
} = useApi(getSendPasscodeApi(type, method));
|
||||
|
||||
useEffect(() => {
|
||||
if (code.length === defaultLength && code.every(Boolean)) {
|
||||
|
|
|
@ -5,18 +5,18 @@ import { useNavigate } from 'react-router-dom';
|
|||
import reactStringReplace from 'react-string-replace';
|
||||
|
||||
import TextLink from '@/components/TextLink';
|
||||
import { SignInMethod } from '@/types';
|
||||
import { LocalSignInMethod } from '@/types';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
signInMethods: SignInMethod[];
|
||||
signInMethods: LocalSignInMethod[];
|
||||
type?: 'primary' | 'secondary';
|
||||
classname?: string;
|
||||
};
|
||||
|
||||
const SignInMethodsKeyMap: {
|
||||
[key in SignInMethod]: TFuncKey<'translation', 'main_flow.input'>;
|
||||
[key in LocalSignInMethod]: TFuncKey<'translation', 'main_flow.input'>;
|
||||
} = {
|
||||
username: 'username',
|
||||
email: 'email',
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
import React from 'react';
|
||||
|
||||
import { SignInExperienceSettings } from '@/types';
|
||||
|
||||
type Context = {
|
||||
toast: string;
|
||||
loading: boolean;
|
||||
setToast: (message: string) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
experienceSettings: SignInExperienceSettings | undefined;
|
||||
setExperienceSettings: (settings: SignInExperienceSettings) => void;
|
||||
};
|
||||
|
||||
const NOOP = () => {
|
||||
|
@ -16,6 +20,8 @@ const PageContext = React.createContext<Context>({
|
|||
loading: false,
|
||||
setToast: NOOP,
|
||||
setLoading: NOOP,
|
||||
experienceSettings: undefined,
|
||||
setExperienceSettings: NOOP,
|
||||
});
|
||||
|
||||
export default PageContext;
|
||||
|
|
|
@ -1,24 +1,33 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useContext } from 'react';
|
||||
|
||||
import { Theme } from '@/components/AppContent';
|
||||
import PageContext from './page-context';
|
||||
|
||||
export type Theme = 'dark' | 'light';
|
||||
|
||||
const darkThemeWatchMedia = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const getThemeBySystemConfiguration = (): Theme => (darkThemeWatchMedia.matches ? 'dark' : 'light');
|
||||
|
||||
export default function useTheme() {
|
||||
const [theme, setTheme] = useState(getThemeBySystemConfiguration());
|
||||
const { experienceSettings } = useContext(PageContext);
|
||||
const [theme, setTheme] = useState<Theme>('light');
|
||||
|
||||
useEffect(() => {
|
||||
if (!experienceSettings?.branding.isDarkModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changeTheme = () => {
|
||||
setTheme(getThemeBySystemConfiguration());
|
||||
};
|
||||
|
||||
changeTheme();
|
||||
|
||||
darkThemeWatchMedia.addEventListener('change', changeTheme);
|
||||
|
||||
return () => {
|
||||
darkThemeWatchMedia.removeEventListener('change', changeTheme);
|
||||
};
|
||||
}, []);
|
||||
}, [experienceSettings]);
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
|
|
@ -12,11 +12,11 @@ jest.mock('react-router-dom', () => ({
|
|||
}));
|
||||
|
||||
describe('Passcode Page', () => {
|
||||
it('render with invalid channel should lead to 404 page', () => {
|
||||
it('render with invalid method should lead to 404 page', () => {
|
||||
const { queryByText } = render(
|
||||
<MemoryRouter initialEntries={['/sign-in/username/passcode-validation']}>
|
||||
<Routes>
|
||||
<Route path="/:type/:channel/passcode-validation" element={<Passcode />} />
|
||||
<Route path="/:type/:method/passcode-validation" element={<Passcode />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
@ -28,7 +28,7 @@ describe('Passcode Page', () => {
|
|||
const { queryByText } = render(
|
||||
<MemoryRouter initialEntries={['/sign-in/email/passcode-validation']}>
|
||||
<Routes>
|
||||
<Route path="/:type/:channel/passcode-validation" element={<Passcode />} />
|
||||
<Route path="/:type/:method/passcode-validation" element={<Passcode />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
|
|
@ -11,7 +11,7 @@ import * as styles from './index.module.scss';
|
|||
|
||||
type Parameters = {
|
||||
type: UserFlow;
|
||||
channel: string;
|
||||
method: string;
|
||||
};
|
||||
|
||||
type StateType = Nullable<{
|
||||
|
@ -22,10 +22,10 @@ type StateType = Nullable<{
|
|||
const Passcode = () => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
|
||||
const navigate = useNavigate();
|
||||
const { channel, type } = useParams<Parameters>();
|
||||
const { method, type } = useParams<Parameters>();
|
||||
const state = useLocation().state as StateType;
|
||||
const invalidSignInMethod = type !== 'sign-in' && type !== 'register';
|
||||
const invalidChannel = channel !== 'email' && channel !== 'sms';
|
||||
const invalidChannel = method !== 'email' && method !== 'sms';
|
||||
|
||||
useEffect(() => {
|
||||
if (invalidSignInMethod || invalidChannel) {
|
||||
|
@ -37,7 +37,7 @@ const Passcode = () => {
|
|||
return null;
|
||||
}
|
||||
|
||||
const target = state ? state[channel] : undefined;
|
||||
const target = state ? state[method] : undefined;
|
||||
|
||||
if (!target) {
|
||||
// TODO: no email or phone found
|
||||
|
@ -55,7 +55,7 @@ const Passcode = () => {
|
|||
</div>
|
||||
<div className={styles.title}>{t('action.enter_passcode')}</div>
|
||||
<div className={styles.detail}>{t('description.enter_passcode', { address: target })}</div>
|
||||
<PasscodeValidation type={type} channel={channel} target={target} />
|
||||
<PasscodeValidation type={type} method={method} target={target} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -21,7 +21,7 @@ describe('<Register />', () => {
|
|||
const { queryByText, container } = render(
|
||||
<MemoryRouter initialEntries={['/register/sms']}>
|
||||
<Routes>
|
||||
<Route path="/register/:channel" element={<Register />} />
|
||||
<Route path="/register/:method" element={<Register />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
@ -33,7 +33,7 @@ describe('<Register />', () => {
|
|||
const { queryByText, container } = render(
|
||||
<MemoryRouter initialEntries={['/register/email']}>
|
||||
<Routes>
|
||||
<Route path="/register/:channel" element={<Register />} />
|
||||
<Route path="/register/:method" element={<Register />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
|
|
@ -9,31 +9,31 @@ import { PhonePasswordless, EmailPasswordless } from '@/containers/Passwordless'
|
|||
import * as styles from './index.module.scss';
|
||||
|
||||
type Parameters = {
|
||||
channel?: string;
|
||||
method?: string;
|
||||
};
|
||||
|
||||
const Register = () => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
|
||||
const navigate = useNavigate();
|
||||
const { channel = 'username' } = useParams<Parameters>();
|
||||
const { method = 'username' } = useParams<Parameters>();
|
||||
|
||||
useEffect(() => {
|
||||
if (channel !== 'email' && channel !== 'sms' && channel !== 'username') {
|
||||
if (!['email', 'sms', 'username'].includes(method)) {
|
||||
navigate('/404', { replace: true });
|
||||
}
|
||||
}, [channel, navigate]);
|
||||
}, [method, navigate]);
|
||||
|
||||
const registerForm = useMemo(() => {
|
||||
if (channel === 'sms') {
|
||||
if (method === 'sms') {
|
||||
return <PhonePasswordless type="register" />;
|
||||
}
|
||||
|
||||
if (channel === 'email') {
|
||||
if (method === 'email') {
|
||||
return <EmailPasswordless type="register" />;
|
||||
}
|
||||
|
||||
return <CreateAccount />;
|
||||
}, [channel]);
|
||||
}, [method]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
|
|
|
@ -20,7 +20,7 @@ describe('<SecondarySignIn />', () => {
|
|||
const { queryByText, container } = render(
|
||||
<MemoryRouter initialEntries={['/sign-in/sms']}>
|
||||
<Routes>
|
||||
<Route path="/sign-in/:channel" element={<SecondarySignIn />} />
|
||||
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
@ -32,7 +32,7 @@ describe('<SecondarySignIn />', () => {
|
|||
const { queryByText, container } = render(
|
||||
<MemoryRouter initialEntries={['/sign-in/email']}>
|
||||
<Routes>
|
||||
<Route path="/sign-in/:channel" element={<SecondarySignIn />} />
|
||||
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
|
|
@ -9,31 +9,31 @@ import UsernameSignin from '@/containers/UsernameSignin';
|
|||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
channel?: string;
|
||||
method?: string;
|
||||
};
|
||||
|
||||
const SecondarySignIn = () => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
|
||||
const navigate = useNavigate();
|
||||
const { channel = 'username' } = useParams<Props>();
|
||||
const { method = 'username' } = useParams<Props>();
|
||||
|
||||
useEffect(() => {
|
||||
if (channel !== 'email' && channel !== 'sms' && channel !== 'username') {
|
||||
if (method !== 'email' && method !== 'sms' && method !== 'username') {
|
||||
navigate('/404', { replace: true });
|
||||
}
|
||||
}, [channel, navigate]);
|
||||
}, [method, navigate]);
|
||||
|
||||
const signInForm = useMemo(() => {
|
||||
if (channel === 'sms') {
|
||||
if (method === 'sms') {
|
||||
return <PhonePasswordless type="sign-in" />;
|
||||
}
|
||||
|
||||
if (channel === 'email') {
|
||||
if (method === 'email') {
|
||||
return <EmailPasswordless type="sign-in" />;
|
||||
}
|
||||
|
||||
return <UsernameSignin />;
|
||||
}, [channel]);
|
||||
}, [method]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
|
|
|
@ -1,2 +1,13 @@
|
|||
import { Branding, LanguageInfo, TermsOfUse } from '@logto/schemas';
|
||||
|
||||
export type UserFlow = 'sign-in' | 'register';
|
||||
export type SignInMethod = 'username' | 'email' | 'sms';
|
||||
export type SignInMethod = 'username' | 'email' | 'sms' | 'social';
|
||||
export type LocalSignInMethod = 'username' | 'email' | 'sms';
|
||||
|
||||
export type SignInExperienceSettings = {
|
||||
branding: Branding;
|
||||
languageInfo: LanguageInfo;
|
||||
termsOfUse: TermsOfUse;
|
||||
primarySignInMethod: SignInMethod;
|
||||
secondarySignInMethods: SignInMethod[];
|
||||
};
|
||||
|
|
25
packages/ui/src/utils/sign-in-experience.test.ts
Normal file
25
packages/ui/src/utils/sign-in-experience.test.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { mockSignInExperience } from '@/__mocks__/logto';
|
||||
import { getSignInExperience } from '@/apis/settings';
|
||||
|
||||
import getSignInExperienceSettings from './sign-in-experience';
|
||||
|
||||
jest.mock('@/apis/settings', () => ({
|
||||
getSignInExperience: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('getSignInExperienceSettings', () => {
|
||||
const getSignInExperienceMock = getSignInExperience as jest.Mock;
|
||||
|
||||
it('should return the sign in experience settings', async () => {
|
||||
getSignInExperienceMock.mockResolvedValueOnce(mockSignInExperience);
|
||||
const { settings } = await getSignInExperienceSettings();
|
||||
|
||||
expect(settings?.branding).toEqual(mockSignInExperience.branding);
|
||||
expect(settings?.languageInfo).toEqual(mockSignInExperience.languageInfo);
|
||||
expect(settings?.termsOfUse).toEqual(mockSignInExperience.termsOfUse);
|
||||
expect(settings?.primarySignInMethod).toEqual('username');
|
||||
expect(settings?.secondarySignInMethods).toContain('email');
|
||||
expect(settings?.secondarySignInMethods).toContain('sms');
|
||||
expect(settings?.secondarySignInMethods).toContain('social');
|
||||
});
|
||||
});
|
47
packages/ui/src/utils/sign-in-experience.ts
Normal file
47
packages/ui/src/utils/sign-in-experience.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Temp Solution for getting the sign in experience
|
||||
* TODO: Remove this once we have a better way to get the sign in experience through SSR
|
||||
*/
|
||||
|
||||
import { SignInMethods } from '@logto/schemas';
|
||||
|
||||
import { getSignInExperience } from '@/apis/settings';
|
||||
import { SignInMethod } from '@/types';
|
||||
|
||||
const getPrimarySignInMethod = (signInMethods: SignInMethods) => {
|
||||
for (const [key, value] of Object.entries(signInMethods)) {
|
||||
if (value === 'primary') {
|
||||
return key as keyof SignInMethods;
|
||||
}
|
||||
}
|
||||
|
||||
return 'username';
|
||||
};
|
||||
|
||||
const getSecondarySignInMethod = (signInMethods: SignInMethods) =>
|
||||
Object.entries(signInMethods).reduce<SignInMethod[]>((methods, [key, value]) => {
|
||||
if (value === 'secondary') {
|
||||
return [...methods, key as SignInMethod];
|
||||
}
|
||||
|
||||
return methods;
|
||||
}, []);
|
||||
|
||||
const getSignInExperienceSettings = async () => {
|
||||
try {
|
||||
const result = await getSignInExperience();
|
||||
const settings = {
|
||||
branding: result.branding,
|
||||
languageInfo: result.languageInfo,
|
||||
termsOfUse: result.termsOfUse,
|
||||
primarySignInMethod: getPrimarySignInMethod(result.signInMethods),
|
||||
secondarySignInMethods: getSecondarySignInMethod(result.signInMethods),
|
||||
};
|
||||
|
||||
return { settings };
|
||||
} catch (error: unknown) {
|
||||
return { error };
|
||||
}
|
||||
};
|
||||
|
||||
export default getSignInExperienceSettings;
|
Loading…
Reference in a new issue