mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(schemas,ui): add custom content slot (#3369)
This commit is contained in:
parent
b470e0efb7
commit
57eb6ee452
16 changed files with 118 additions and 19 deletions
9
.changeset-staged/smooth-steaks-cough.md
Normal file
9
.changeset-staged/smooth-steaks-cough.md
Normal file
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
"@logto/core": minor
|
||||
"@logto/schemas": minor
|
||||
"@logto/ui": minor
|
||||
---
|
||||
|
||||
### Add custom content sign-in-experience settings to allow insert custom static html content to the logto sign-in pages
|
||||
|
||||
- feat: combine with the custom css, give the user the ability to further customize the sign-in pages
|
|
@ -90,4 +90,5 @@ export const mockSignInExperience: SignInExperience = {
|
|||
socialSignInConnectorTargets: ['github', 'facebook', 'wechat'],
|
||||
signInMode: SignInMode.SignInAndRegister,
|
||||
customCss: null,
|
||||
customContent: {},
|
||||
};
|
||||
|
|
|
@ -30,12 +30,13 @@ describe('sign-in-experience query', () => {
|
|||
signIn: JSON.stringify(mockSignInExperience.signIn),
|
||||
signUp: JSON.stringify(mockSignInExperience.signUp),
|
||||
socialSignInConnectorTargets: JSON.stringify(mockSignInExperience.socialSignInConnectorTargets),
|
||||
customContent: JSON.stringify(mockSignInExperience.customContent),
|
||||
};
|
||||
|
||||
it('findDefaultSignInExperience', async () => {
|
||||
/* eslint-disable sql/no-unsafe-query */
|
||||
const expectSql = `
|
||||
select "tenant_id", "id", "color", "branding", "language_info", "terms_of_use_url", "privacy_policy_url", "sign_in", "sign_up", "social_sign_in_connector_targets", "sign_in_mode", "custom_css"
|
||||
select "tenant_id", "id", "color", "branding", "language_info", "terms_of_use_url", "privacy_policy_url", "sign_in", "sign_up", "social_sign_in_connector_targets", "sign_in_mode", "custom_css", "custom_content"
|
||||
from "sign_in_experiences"
|
||||
where "id"=$1
|
||||
`;
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
import { languages, languageTagGuard } from '@logto/language-kit';
|
||||
import { ApplicationType, arbitraryObjectGuard, translationGuard } from '@logto/schemas';
|
||||
import {
|
||||
ApplicationType,
|
||||
arbitraryObjectGuard,
|
||||
translationGuard,
|
||||
customContentGuard,
|
||||
} from '@logto/schemas';
|
||||
import { string, boolean, number, object, nativeEnum, unknown, literal, union } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
|
@ -220,4 +225,13 @@ describe('zodTypeToSwagger', () => {
|
|||
new RequestError('swagger.invalid_zod_type', 'test')
|
||||
);
|
||||
});
|
||||
|
||||
it('record type', () => {
|
||||
expect(zodTypeToSwagger(customContentGuard)).toEqual({
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
type: 'string',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -240,6 +240,13 @@ export const zodTypeToSwagger = (
|
|||
};
|
||||
}
|
||||
|
||||
if (config instanceof ZodRecord) {
|
||||
return {
|
||||
type: 'object',
|
||||
additionalProperties: zodTypeToSwagger(config.valueSchema),
|
||||
};
|
||||
}
|
||||
|
||||
// TO-DO: Improve swagger output for zod schema with refinement (validate through JS functions)
|
||||
if (config instanceof ZodEffects && config._def.effect.type === 'refinement') {
|
||||
return {
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import { sql } from 'slonik';
|
||||
|
||||
import type { AlterationScript } from '../lib/types/alteration.js';
|
||||
|
||||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
await pool.query(sql`
|
||||
alter table sign_in_experiences
|
||||
add column if not exists custom_content jsonb not null default '{}'::jsonb;
|
||||
`);
|
||||
},
|
||||
down: async (pool) => {
|
||||
await pool.query(sql`
|
||||
alter table sign_in_experiences
|
||||
drop column custom_content;
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
export default alteration;
|
|
@ -143,8 +143,16 @@ export const connectorTargetsGuard = z.string().array();
|
|||
|
||||
export type ConnectorTargets = z.infer<typeof connectorTargetsGuard>;
|
||||
|
||||
export const customContentGuard = z.record(z.string());
|
||||
|
||||
export type CustomContent = z.infer<typeof customContentGuard>;
|
||||
|
||||
/* === Logto Configs === */
|
||||
|
||||
/**
|
||||
* Settings
|
||||
*/
|
||||
|
||||
export enum AppearanceMode {
|
||||
SyncWithSystem = 'system',
|
||||
LightMode = 'light',
|
||||
|
|
|
@ -44,6 +44,7 @@ export const createDefaultSignInExperience = (forTenantId: string): Readonly<Sig
|
|||
socialSignInConnectorTargets: [],
|
||||
signInMode: SignInMode.SignInAndRegister,
|
||||
customCss: null,
|
||||
customContent: {},
|
||||
});
|
||||
|
||||
/** @deprecated Use `createDefaultSignInExperience()` instead. */
|
||||
|
|
|
@ -14,5 +14,6 @@ create table sign_in_experiences (
|
|||
social_sign_in_connector_targets jsonb /* @use ConnectorTargets */ not null default '[]'::jsonb,
|
||||
sign_in_mode sign_in_mode not null default 'SignInAndRegister',
|
||||
custom_css text,
|
||||
custom_content jsonb /* @use CustomContent */ not null default '{}'::jsonb,
|
||||
primary key (tenant_id, id)
|
||||
);
|
||||
|
|
28
packages/ui/src/Layout/AppLayout/CustomContent.tsx
Normal file
28
packages/ui/src/Layout/AppLayout/CustomContent.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CustomContent = ({ className }: Props) => {
|
||||
const { customContent } = useSieMethods();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const customHtml = customContent?.[pathname];
|
||||
|
||||
if (!customHtml) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Expected error; CustomContent content is load from Logto remote server
|
||||
// eslint-disable-next-line react/no-danger
|
||||
return <div dangerouslySetInnerHTML={{ __html: customHtml }} className={className} />;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default CustomContent;
|
|
@ -24,18 +24,13 @@
|
|||
|
||||
.container {
|
||||
min-height: 100%;
|
||||
@include _.flex_column(center, normal);
|
||||
@include _.flex_column(center, center);
|
||||
}
|
||||
|
||||
.main {
|
||||
@include _.flex_column;
|
||||
}
|
||||
|
||||
.placeHolder {
|
||||
flex: 1;
|
||||
min-height: _.unit(5);
|
||||
}
|
||||
|
||||
:global(body.mobile) {
|
||||
.container {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
|
@ -48,13 +43,13 @@
|
|||
position: relative;
|
||||
background: var(--color-bg-body);
|
||||
}
|
||||
|
||||
.placeHolder {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global(body.desktop) {
|
||||
.container {
|
||||
padding: _.unit(5);
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 640px;
|
||||
min-height: 640px;
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import classNames from 'classnames';
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
import LogtoSignature from '@/components/LogtoSignature';
|
||||
import usePlatform from '@/hooks/use-platform';
|
||||
import { layoutClassNames } from '@/utils/consts';
|
||||
import { parseHtmlTitle } from '@/utils/sign-in-experience';
|
||||
|
||||
import CustomContent from './CustomContent';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const AppLayout = () => {
|
||||
|
@ -23,14 +26,13 @@ const AppLayout = () => {
|
|||
|
||||
return (
|
||||
<div className={styles.viewBox}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.placeHolder} />
|
||||
<main id="main-form" className={styles.main}>
|
||||
<div className={classNames(styles.container, layoutClassNames.pageContainer)}>
|
||||
{!isMobile && <CustomContent className={layoutClassNames.customContent} />}
|
||||
<main className={classNames(styles.main, layoutClassNames.mainContent)}>
|
||||
<Outlet />
|
||||
{isMobile && <LogtoSignature />}
|
||||
{isMobile && <LogtoSignature className={layoutClassNames.signature} />}
|
||||
</main>
|
||||
{!isMobile && <LogtoSignature />}
|
||||
<div className={styles.placeHolder} />
|
||||
{!isMobile && <LogtoSignature className={layoutClassNames.signature} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -5,6 +5,7 @@ import type { TFuncKey } from 'react-i18next';
|
|||
|
||||
import BrandingHeader from '@/components/BrandingHeader';
|
||||
import { PageContext } from '@/hooks/use-page-context';
|
||||
import { layoutClassNames } from '@/utils/consts';
|
||||
import { getBrandingLogoUrl } from '@/utils/logo';
|
||||
|
||||
import AppNotification from '../../containers/AppNotification';
|
||||
|
@ -33,7 +34,7 @@ const LandingPageLayout = ({ children, className, title }: Props) => {
|
|||
{platform === 'web' && <div className={styles.placeholderTop} />}
|
||||
<div className={classNames(styles.wrapper, className)}>
|
||||
<BrandingHeader
|
||||
className={styles.header}
|
||||
className={classNames(styles.header, layoutClassNames.brandingHeader)}
|
||||
headline={title}
|
||||
logo={getBrandingLogoUrl({ theme, branding, isDarkModeEnabled })}
|
||||
/>
|
||||
|
|
|
@ -203,6 +203,7 @@ export const mockSignInExperience: SignInExperience = {
|
|||
socialSignInConnectorTargets: ['BE8QXN0VsrOH7xdWFDJZ9', 'lcXT4o2GSjbV9kg2shZC7'],
|
||||
signInMode: SignInMode.SignInAndRegister,
|
||||
customCss: null,
|
||||
customContent: {},
|
||||
};
|
||||
|
||||
export const mockSignInExperienceSettings: SignInExperienceResponse = {
|
||||
|
@ -226,6 +227,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = {
|
|||
phone: true,
|
||||
},
|
||||
customCss: null,
|
||||
customContent: {},
|
||||
};
|
||||
|
||||
const usernameSettings = {
|
||||
|
|
|
@ -20,6 +20,8 @@ export const useSieMethods = () => {
|
|||
socialConnectors: experienceSettings?.socialConnectors ?? [],
|
||||
signInMode: experienceSettings?.signInMode,
|
||||
forgotPassword: experienceSettings?.forgotPassword,
|
||||
customCss: experienceSettings?.customCss,
|
||||
customContent: experienceSettings?.customContent,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
7
packages/ui/src/utils/consts.ts
Normal file
7
packages/ui/src/utils/consts.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export const layoutClassNames = Object.freeze({
|
||||
pageContainer: 'logto_page-container',
|
||||
mainContent: 'logto_main-content',
|
||||
customContent: 'logto_custom-content',
|
||||
signature: 'logto_signature',
|
||||
brandingHeader: 'logto_branding-header',
|
||||
});
|
Loading…
Reference in a new issue