0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

feat(console): sie form reorg (#1218)

This commit is contained in:
Wang Sijie 2022-06-24 10:26:30 +08:00 committed by GitHub
parent 9aca422578
commit 2c413341d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 174 additions and 89 deletions

View file

@ -73,7 +73,7 @@ const Main = () => {
<Route path=":userId/logs/:logId" element={<AuditLogDetails />} />
</Route>
<Route path="sign-in-experience">
<Route index element={<Navigate replace to="experience" />} />
<Route index element={<Navigate replace to="branding" />} />
<Route path=":tab" element={<SignInExperience />} />
</Route>
<Route path="settings" element={<Settings />} />

View file

@ -3,10 +3,8 @@ import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import ColorPicker from '@/components/ColorPicker';
import FormField from '@/components/FormField';
import RadioGroup, { Radio } from '@/components/RadioGroup';
import Switch from '@/components/Switch';
import TextInput from '@/components/TextInput';
import { uriValidator } from '@/utilities/validator';
@ -22,39 +20,13 @@ const BrandingForm = () => {
formState: { errors },
} = useFormContext<SignInExperienceForm>();
const isDarkModeEnabled = watch('branding.isDarkModeEnabled');
const isDarkModeEnabled = watch('color.isDarkModeEnabled');
const style = watch('branding.style');
const isSloganRequired = style === BrandingStyle.Logo_Slogan;
return (
<>
<div className={styles.title}>{t('sign_in_exp.branding.title')}</div>
<FormField title="admin_console.sign_in_exp.branding.primary_color">
<Controller
name="branding.primaryColor"
control={control}
render={({ field: { onChange, value } }) => (
<ColorPicker value={value} onChange={onChange} />
)}
/>
</FormField>
<FormField title="admin_console.sign_in_exp.branding.dark_mode">
<Switch
label={t('sign_in_exp.branding.dark_mode_description')}
{...register('branding.isDarkModeEnabled')}
/>
</FormField>
{isDarkModeEnabled && (
<FormField title="admin_console.sign_in_exp.branding.dark_primary_color">
<Controller
name="branding.darkPrimaryColor"
control={control}
render={({ field: { onChange, value } }) => (
<ColorPicker value={value} onChange={onChange} />
)}
/>
</FormField>
)}
<FormField title="admin_console.sign_in_exp.branding.ui_style">
<Controller
name="branding.style"

View file

@ -0,0 +1,51 @@
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import ColorPicker from '@/components/ColorPicker';
import FormField from '@/components/FormField';
import Switch from '@/components/Switch';
import { SignInExperienceForm } from '../types';
import * as styles from './index.module.scss';
const ColorForm = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { watch, register, control } = useFormContext<SignInExperienceForm>();
const isDarkModeEnabled = watch('color.isDarkModeEnabled');
return (
<>
<div className={styles.title}>{t('sign_in_exp.color.title')}</div>
<FormField title="admin_console.sign_in_exp.color.primary_color">
<Controller
name="color.primaryColor"
control={control}
render={({ field: { onChange, value } }) => (
<ColorPicker value={value} onChange={onChange} />
)}
/>
</FormField>
<FormField title="admin_console.sign_in_exp.color.dark_mode">
<Switch
label={t('sign_in_exp.color.dark_mode_description')}
{...register('color.isDarkModeEnabled')}
/>
</FormField>
{isDarkModeEnabled && (
<FormField title="admin_console.sign_in_exp.color.dark_primary_color">
<Controller
name="color.darkPrimaryColor"
control={control}
render={({ field: { onChange, value } }) => (
<ColorPicker value={value} onChange={onChange} />
)}
/>
</FormField>
)}
</>
);
};
export default ColorForm;

View file

@ -20,6 +20,7 @@ import usePreviewConfigs from '../hooks';
import { SignInExperienceForm } from '../types';
import { signInExperienceParser } from '../utilities';
import BrandingForm from './BrandingForm';
import ColorForm from './ColorForm';
import * as styles from './GuideModal.module.scss';
import LanguagesForm from './LanguagesForm';
import Preview from './Preview';
@ -100,6 +101,9 @@ const GuideModal = ({ isOpen, onClose }: Props) => {
<form onSubmit={onSubmit}>
<div className={styles.main}>
<div className={styles.form}>
<div className={styles.card}>
<ColorForm />
</div>
<div className={styles.card}>
<BrandingForm />
</div>

View file

@ -17,6 +17,7 @@ import useSettings from '@/hooks/use-settings';
import * as detailsStyles from '@/scss/details.module.scss';
import BrandingForm from './components/BrandingForm';
import ColorForm from './components/ColorForm';
import LanguagesForm from './components/LanguagesForm';
import Preview from './components/Preview';
import SignInMethodsChangePreview from './components/SignInMethodsChangePreview';
@ -100,8 +101,8 @@ const SignInExperience = () => {
<Card className={styles.card}>
<CardTitle title="sign_in_exp.title" subtitle="sign_in_exp.description" />
<TabNav className={styles.tabs}>
<TabNavItem href="/sign-in-experience/experience">
{t('sign_in_exp.tabs.experience')}
<TabNavItem href="/sign-in-experience/branding">
{t('sign_in_exp.tabs.branding')}
</TabNavItem>
<TabNavItem href="/sign-in-experience/methods">
{t('sign_in_exp.tabs.methods')}
@ -116,14 +117,19 @@ const SignInExperience = () => {
<FormProvider {...methods}>
<form onSubmit={onSubmit}>
<div className={classNames(detailsStyles.body, styles.form)}>
{tab === 'experience' && (
{tab === 'branding' && (
<>
<ColorForm />
<BrandingForm />
<TermsForm />
</>
)}
{tab === 'methods' && <SignInMethodsForm />}
{tab === 'others' && <LanguagesForm />}
{tab === 'others' && (
<>
<TermsForm />
<LanguagesForm />
</>
)}
</div>
<div className={detailsStyles.footer}>
<div className={detailsStyles.footerMain}>

View file

@ -4,6 +4,7 @@ import {
SignInMethods,
SignInMethodState,
} from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { LanguageMode, SignInExperienceForm } from './types';
@ -57,18 +58,23 @@ export const signInExperienceParser = {
},
toRemoteModel: (setup: SignInExperienceForm): SignInExperience => {
const {
color,
branding,
languageInfo: { mode, fallbackLanguage, fixedLanguage },
} = setup;
return {
...setup,
color: {
...color,
// Transform empty string to undefined
darkPrimaryColor: conditional(color.darkPrimaryColor?.length && color.darkPrimaryColor),
},
branding: {
...branding,
// Transform empty string to undefined
darkPrimaryColor: branding.darkPrimaryColor?.length ? branding.darkPrimaryColor : undefined,
darkLogoUrl: branding.darkLogoUrl?.length ? branding.darkLogoUrl : undefined,
slogan: branding.slogan?.length ? branding.slogan : undefined,
darkLogoUrl: conditional(branding.darkLogoUrl?.length && branding.darkLogoUrl),
slogan: conditional(branding.slogan?.length && branding.slogan),
},
signInMethods: {
username: findMethodState(setup, 'username'),

View file

@ -8,14 +8,17 @@ import {
SignInMethodState,
TermsOfUse,
SignInMode,
Color,
} from '@logto/schemas';
export const mockSignInExperience: SignInExperience = {
id: 'foo',
branding: {
color: {
primaryColor: '#000',
isDarkModeEnabled: true,
darkPrimaryColor: '#fff',
},
branding: {
style: BrandingStyle.Logo,
logoUrl: 'http://logto.png',
slogan: 'logto',
@ -38,10 +41,13 @@ export const mockSignInExperience: SignInExperience = {
signInMode: SignInMode.SignInAndRegister,
};
export const mockBranding: Branding = {
export const mockColor: Color = {
primaryColor: '#000',
isDarkModeEnabled: true,
darkPrimaryColor: '#fff',
};
export const mockBranding: Branding = {
style: BrandingStyle.Logo_Slogan,
logoUrl: 'http://silverhand.png',
slogan: 'Silverhand.',

View file

@ -21,6 +21,7 @@ describe('sign-in-experience query', () => {
const dbvalue = {
...mockSignInExperience,
color: JSON.stringify(mockSignInExperience.color),
branding: JSON.stringify(mockSignInExperience.branding),
termsOfUse: JSON.stringify(mockSignInExperience.termsOfUse),
languageInfo: JSON.stringify(mockSignInExperience.languageInfo),
@ -31,7 +32,7 @@ describe('sign-in-experience query', () => {
it('findDefaultSignInExperience', async () => {
/* eslint-disable sql/no-unsafe-query */
const expectSql = `
select "id", "branding", "language_info", "terms_of_use", "sign_in_methods", "social_sign_in_connector_targets", "sign_in_mode"
select "id", "color", "branding", "language_info", "terms_of_use", "sign_in_methods", "social_sign_in_connector_targets", "sign_in_mode"
from "sign_in_experiences"
where "id" = $1
`;

View file

@ -26,30 +26,6 @@ const expectPatchResponseStatus = async (signInExperience: any, status: number)
};
describe('branding', () => {
const colorKeys = ['primaryColor', 'darkPrimaryColor'];
const invalidColors = [null, '#0'];
describe('colors', () => {
test.each(invalidColors)('should fail when color is %p', async (invalidColor) => {
for (const colorKey of colorKeys) {
// eslint-disable-next-line no-await-in-loop
await expectPatchResponseStatus(
{ branding: { ...mockBranding, [colorKey]: invalidColor } },
400
);
}
});
it('should succeed when color is valid', async () => {
for (const colorKey of colorKeys) {
// eslint-disable-next-line no-await-in-loop
await expectPatchResponseStatus(
{ branding: { ...mockBranding, [colorKey]: '#169deF' } },
200
);
}
});
});
describe('style', () => {
test.each(Object.values(BrandingStyle))('%p should succeed', async (style) => {
const signInExperience = { branding: { ...mockBranding, style } };

View file

@ -0,0 +1,44 @@
import { CreateSignInExperience, SignInExperience } from '@logto/schemas';
import { mockColor, mockSignInExperience } from '@/__mocks__';
import { createRequester } from '@/utils/test-utils';
import signInExperiencesRoutes from './sign-in-experience';
jest.mock('@/queries/sign-in-experience', () => ({
updateDefaultSignInExperience: jest.fn(
async (data: Partial<CreateSignInExperience>): Promise<SignInExperience> => ({
...mockSignInExperience,
...data,
})
),
}));
jest.mock('@/connectors', () => ({
getConnectorInstances: jest.fn(async () => []),
}));
const signInExperienceRequester = createRequester({ authedRoutes: signInExperiencesRoutes });
const expectPatchResponseStatus = async (signInExperience: any, status: number) => {
const response = await signInExperienceRequester.patch('/sign-in-exp').send(signInExperience);
expect(response.status).toEqual(status);
};
const colorKeys = ['primaryColor', 'darkPrimaryColor'];
const invalidColors = [null, '#0'];
describe('colors', () => {
test.each(invalidColors)('should fail when color is %p', async (invalidColor) => {
for (const colorKey of colorKeys) {
// eslint-disable-next-line no-await-in-loop
await expectPatchResponseStatus({ color: { ...mockColor, [colorKey]: invalidColor } }, 400);
}
});
it('should succeed when color is valid', async () => {
for (const colorKey of colorKeys) {
// eslint-disable-next-line no-await-in-loop
await expectPatchResponseStatus({ color: { ...mockColor, [colorKey]: '#169deF' } }, 200);
}
});
});

View file

@ -13,6 +13,7 @@ import {
mockSignInExperience,
mockSignInMethods,
mockWechatConnectorInstance,
mockColor,
} from '@/__mocks__';
import * as signInExpLib from '@/lib/sign-in-experience';
import { createRequester } from '@/utils/test-utils';
@ -142,6 +143,7 @@ describe('PATCH /sign-in-exp', () => {
const validateSignInMethods = jest.spyOn(signInExpLib, 'validateSignInMethods');
const response = await signInExperienceRequester.patch('/sign-in-exp').send({
color: mockColor,
branding: mockBranding,
termsOfUse,
signInMethods: mockSignInMethods,
@ -160,6 +162,7 @@ describe('PATCH /sign-in-exp', () => {
status: 200,
body: {
...mockSignInExperience,
color: mockColor,
branding: mockBranding,
termsOfUse,
signInMethods: mockSignInMethods,

View file

@ -406,7 +406,7 @@ const translation = {
title: 'Sign-in Experience',
description: 'Customize the sign in UI to match your brand and preview in real time.',
tabs: {
experience: 'Experience',
branding: 'Branding',
methods: 'Sign in methods',
others: 'Others',
},
@ -418,14 +418,17 @@ const translation = {
'Please note that sign-in experience will apply to all applications under this account.',
got_it: 'Got it',
},
branding: {
title: 'BRANDING',
color: {
title: 'COLOR',
primary_color: 'Brand color',
dark_primary_color: 'Brand color (Dark)',
dark_mode: 'Enable dark mode',
dark_mode_description:
'Your app will have an auto-generated dark mode theme based on your brand color and Logto algorithm. You are free to customize.',
ui_style: 'Branding area',
},
branding: {
title: 'BRANDING AREA',
ui_style: 'Style',
styles: {
logo_slogan: 'App logo with slogan',
logo: 'App logo',

View file

@ -396,7 +396,7 @@ const translation = {
title: '登录体验',
description: '自定义登录界面,并实时预览真实效果。',
tabs: {
experience: '体验',
branding: '品牌',
methods: '登录方式',
others: '其它',
},
@ -406,14 +406,17 @@ const translation = {
apply_remind: '请注意,登录体验将会应用到当前账户下的所有应用。',
got_it: '知道了',
},
branding: {
title: '品牌',
color: {
title: '颜色',
primary_color: '品牌颜色',
dark_primary_color: '品牌颜色 (暗黑)',
dark_mode: '开启暗黑模式',
dark_primary_color: '品牌颜色 (深色)',
dark_mode: '开启深色模式',
dark_mode_description:
'基于你的品牌颜色和 Logto 的算法,你的应用将会有一个自动生成的暗黑模式。当然,你可以自定义和修改。',
ui_style: '品牌定制区',
'基于你的品牌颜色和 Logto 的算法,你的应用将会有一个自动生成的深色模式。当然,你可以自定义和修改。',
},
branding: {
title: '品牌定制区',
ui_style: '样式',
styles: {
logo_slogan: 'App logo 和标语',
logo: '仅有Logo',

View file

@ -72,15 +72,20 @@ export type Identities = z.infer<typeof identitiesGuard>;
* SignIn Experiences
*/
export const colorGuard = z.object({
primaryColor: z.string().regex(hexColorRegEx),
isDarkModeEnabled: z.boolean(),
darkPrimaryColor: z.string().regex(hexColorRegEx).optional(),
});
export type Color = z.infer<typeof colorGuard>;
export enum BrandingStyle {
Logo = 'Logo',
Logo_Slogan = 'Logo_Slogan',
}
export const brandingGuard = z.object({
primaryColor: z.string().regex(hexColorRegEx),
isDarkModeEnabled: z.boolean(),
darkPrimaryColor: z.string().regex(hexColorRegEx).optional(),
style: z.nativeEnum(BrandingStyle),
logoUrl: z.string().url(),
darkLogoUrl: z.string().url().optional(),

View file

@ -5,10 +5,12 @@ import { BrandingStyle, SignInMethodState } from '../foundations';
export const defaultSignInExperience: Readonly<CreateSignInExperience> = {
id: 'default',
branding: {
color: {
primaryColor: '#6139F6',
isDarkModeEnabled: false,
darkPrimaryColor: '#6139F6',
},
branding: {
style: BrandingStyle.Logo,
logoUrl: 'https://logto.io/logo.svg',
darkLogoUrl: 'https://logto.io/logo.svg',

View file

@ -2,6 +2,7 @@ create type sign_in_mode as enum ('SignIn', 'Register', 'SignInAndRegister');
create table sign_in_experiences (
id varchar(21) not null,
color jsonb /* @use Color */ not null,
branding jsonb /* @use Branding */ not null,
language_info jsonb /* @use LanguageInfo */ not null,
terms_of_use jsonb /* @use TermsOfUse */ not null,

View file

@ -123,10 +123,12 @@ export const mockSocialConnectorData = {
export const mockSignInExperience: SignInExperience = {
id: 'foo',
branding: {
color: {
primaryColor: '#000',
isDarkModeEnabled: true,
darkPrimaryColor: '#fff',
},
branding: {
style: BrandingStyle.Logo_Slogan,
logoUrl: 'http://logto.png',
slogan: 'logto',
@ -151,6 +153,7 @@ export const mockSignInExperience: SignInExperience = {
};
export const mockSignInExperienceSettings: SignInExperienceSettings = {
color: mockSignInExperience.color,
branding: mockSignInExperience.branding,
termsOfUse: mockSignInExperience.termsOfUse,
languageInfo: mockSignInExperience.languageInfo,

View file

@ -22,10 +22,7 @@ const AppContent = ({ children }: Props) => {
}, [setToast]);
// Set Primary Color
useColorTheme(
experienceSettings?.branding.primaryColor,
experienceSettings?.branding.darkPrimaryColor
);
useColorTheme(experienceSettings?.color.primaryColor, experienceSettings?.color.darkPrimaryColor);
// Set Theme Mode
useEffect(() => {

View file

@ -60,7 +60,7 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => {
}
const {
signInExperience: { signInMethods, socialConnectors, branding, ...rest },
signInExperience: { signInMethods, socialConnectors, color, ...rest },
language,
mode,
platform,
@ -68,8 +68,8 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => {
const experienceSettings: SignInExperienceSettings = {
...rest,
branding: {
...branding,
color: {
...color,
isDarkModeEnabled: false, // Disable theme mode auto detection on preview
},
primarySignInMethod: getPrimarySignInMethod(signInMethods),

View file

@ -11,7 +11,7 @@ export default function useTheme(): Theme {
const { experienceSettings, theme, setTheme } = useContext(PageContext);
useEffect(() => {
if (!experienceSettings?.branding.isDarkModeEnabled) {
if (!experienceSettings?.color.isDarkModeEnabled) {
return;
}

View file

@ -5,6 +5,7 @@ import {
SignInExperience,
ConnectorMetadata,
SignInMode,
Color,
} from '@logto/schemas';
export type UserFlow = 'sign-in' | 'register';
@ -30,6 +31,7 @@ export type SignInExperienceSettingsResponse = SignInExperience & {
};
export type SignInExperienceSettings = {
color: Color;
branding: Branding;
languageInfo: LanguageInfo;
termsOfUse: TermsOfUse;