0
Fork 0
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:
simeng-li 2023-03-14 11:06:01 +08:00 committed by GitHub
parent b470e0efb7
commit 57eb6ee452
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 118 additions and 19 deletions

View 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

View file

@ -90,4 +90,5 @@ export const mockSignInExperience: SignInExperience = {
socialSignInConnectorTargets: ['github', 'facebook', 'wechat'],
signInMode: SignInMode.SignInAndRegister,
customCss: null,
customContent: {},
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -44,6 +44,7 @@ export const createDefaultSignInExperience = (forTenantId: string): Readonly<Sig
socialSignInConnectorTargets: [],
signInMode: SignInMode.SignInAndRegister,
customCss: null,
customContent: {},
});
/** @deprecated Use `createDefaultSignInExperience()` instead. */

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,6 +20,8 @@ export const useSieMethods = () => {
socialConnectors: experienceSettings?.socialConnectors ?? [],
signInMode: experienceSettings?.signInMode,
forgotPassword: experienceSettings?.forgotPassword,
customCss: experienceSettings?.customCss,
customContent: experienceSettings?.customContent,
};
};

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