0
Fork 0
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:
simeng-li 2022-04-14 11:39:48 +08:00 committed by GitHub
parent f0a961225d
commit 99e425496f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 243 additions and 86 deletions

View file

@ -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>
);
};

View file

@ -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'],
};

View 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>();
};

View file

@ -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;
}

View file

@ -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>
);
};

View file

@ -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');

View file

@ -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)) {

View file

@ -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',

View file

@ -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;

View file

@ -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;
}

View file

@ -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>
);

View file

@ -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>
);
};

View file

@ -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>
);

View file

@ -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}>

View file

@ -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>
);

View file

@ -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}>

View file

@ -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[];
};

View 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');
});
});

View 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;