0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(console): implement custom ui assets upload component (#6217)

This commit is contained in:
Charles Zhao 2024-07-18 15:56:09 +08:00 committed by GitHub
parent 6963192ac5
commit 17d7be3992
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 471 additions and 69 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 82 KiB

View file

@ -0,0 +1,62 @@
@use '@/scss/underscore' as _;
.placeholder {
display: flex;
align-items: center;
padding: _.unit(5) _.unit(5) _.unit(5) _.unit(4);
border: 1px solid var(--color-border);
border-radius: 12px;
gap: _.unit(4);
position: relative;
overflow: hidden;
.main {
display: flex;
flex-direction: column;
gap: _.unit(0.5);
flex: 1;
.name {
font: var(--font-label-2);
color: var(--color-text-primary);
}
.secondaryInfo {
display: flex;
align-items: center;
gap: _.unit(3);
}
.info {
font: var(--font-body-3);
color: var(--color-text-secondary);
}
.error {
font: var(--font-body-3);
color: var(--color-error);
}
}
.icon {
width: 40px;
height: 40px;
}
// display a fake progress bar on the bottom with animations
.progressBar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 4px;
background-color: var(--color-primary);
transform: scaleX(0);
transform-origin: left;
transition: transform 0.3s;
}
&.hasError {
border-color: var(--color-error);
}
}

View file

@ -0,0 +1,89 @@
import { type CustomUiAssets, maxUploadFileSize, type AllowedUploadMimeType } from '@logto/schemas';
import { format } from 'date-fns/fp';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import DeleteIcon from '@/assets/icons/delete.svg';
import IconButton from '@/ds-components/IconButton';
import FileUploader from '@/ds-components/Uploader/FileUploader';
import { formatBytes } from '@/utils/uploader';
import FileIcon from '../FileIcon';
import * as styles from './index.module.scss';
type Props = {
readonly value?: CustomUiAssets;
readonly onChange: (value: CustomUiAssets) => void;
};
const allowedMimeTypes: AllowedUploadMimeType[] = ['application/zip'];
function CustomUiAssetsUploader({ value, onChange }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [file, setFile] = useState<File>();
const [error, setError] = useState<string>();
const showUploader = !value?.id && !file && !error;
const onComplete = useCallback(
(id: string) => {
setFile(undefined);
onChange({ id, createdAt: Date.now() });
},
[onChange]
);
const onErrorChange = useCallback(
(errorMessage?: string, files?: File[]) => {
if (errorMessage) {
setError(errorMessage);
}
if (files?.length) {
setFile(files[0]);
}
},
[setError, setFile]
);
if (showUploader) {
return (
<FileUploader<{ customUiAssetId: string }>
allowedMimeTypes={allowedMimeTypes}
maxSize={maxUploadFileSize}
uploadUrl="api/sign-in-exp/default/custom-ui-assets"
onCompleted={({ customUiAssetId }) => {
onComplete(customUiAssetId);
}}
onUploadErrorChange={onErrorChange}
/>
);
}
return (
<div className={styles.placeholder}>
<FileIcon />
<div className={styles.main}>
<div className={styles.name}>{file?.name ?? t('sign_in_exp.custom_ui.title')}</div>
<div className={styles.secondaryInfo}>
{!!value?.createdAt && (
<span className={styles.info}>{format('yyyy/MM/dd HH:mm')(value.createdAt)}</span>
)}
{file && <span className={styles.info}>{formatBytes(file.size)}</span>}
{error && <span className={styles.error}>{error}</span>}
</div>
</div>
<IconButton
onClick={() => {
setFile(undefined);
setError(undefined);
onChange({ id: '', createdAt: 0 });
}}
>
<DeleteIcon />
</IconButton>
{file && <div className={styles.progressBar} />}
</div>
);
}
export default CustomUiAssetsUploader;

View file

@ -0,0 +1,20 @@
import { Theme } from '@logto/schemas';
import { type ReactNode } from 'react';
import FileIconDark from '@/assets/icons/file-icon-dark.svg';
import FileIconLight from '@/assets/icons/file-icon.svg';
import useTheme from '@/hooks/use-theme';
const themeToRoleIcon = Object.freeze({
[Theme.Light]: <FileIconLight />,
[Theme.Dark]: <FileIconDark />,
} satisfies Record<Theme, ReactNode>);
/** Render a role icon according to the current theme. */
const FileIcon = () => {
const theme = useTheme();
return themeToRoleIcon[theme];
};
export default FileIcon;

View file

@ -137,7 +137,9 @@ function ImageInputs<FormContext extends FieldValues>({
actionDescription={t(`sign_in_exp.branding.with_${field.theme}`, {
value: t(`sign_in_exp.branding_uploads.${field.type}.title`),
})}
onCompleted={onChange}
onCompleted={({ url }) => {
onChange(url);
}}
// Noop fallback should not be necessary, but for TypeScript to be happy
onUploadErrorChange={uploadErrorChangeHandlers[field.name] ?? noop}
onDelete={() => {

View file

@ -85,4 +85,28 @@
}
}
}
&.disabled {
background: url('raw:../../assets/images/blur-preview.svg') 0 0 / 100% auto no-repeat;
}
.placeholder {
width: 100%;
height: calc(_screenSize.$web-height + _.unit(20));
padding: _.unit(10);
backdrop-filter: blur(25px);
display: flex;
flex-direction: column;
color: var(--color-static-white);
.title {
font: var(--font-label-2);
}
.description {
margin-top: _.unit(1.5);
font: var(--font-body-2);
white-space: pre-wrap;
}
}
}

View file

@ -4,7 +4,15 @@ import type { ConnectorMetadata, SignInExperience, ConnectorResponse } from '@lo
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import { format } from 'date-fns';
import { useContext, useRef, useMemo, useCallback, useEffect, useState } from 'react';
import {
useContext,
useRef,
useMemo,
useCallback,
useEffect,
useState,
type ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
@ -28,6 +36,16 @@ type Props = {
* the `AppDataContext` will be used.
*/
readonly endpoint?: URL;
/**
* Whether the preview is disabled. If `true`, the preview will be disabled and a placeholder will
* be shown instead. Defaults to `false`.
*/
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
/**
* The placeholder to show when the preview is disabled.
*/
readonly disabledPlaceholder?: ReactNode;
};
function SignInExperiencePreview({
@ -36,6 +54,8 @@ function SignInExperiencePreview({
language = 'en',
signInExperience,
endpoint: endpointInput,
disabled = false,
disabledPlaceholder,
}: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -97,6 +117,10 @@ function SignInExperiencePreview({
}, []);
useEffect(() => {
if (disabled) {
setIframeLoaded(false);
return;
}
const iframe = previewRef.current;
iframe?.addEventListener('load', iframeOnLoadEventHandler);
@ -104,7 +128,7 @@ function SignInExperiencePreview({
return () => {
iframe?.removeEventListener('load', iframeOnLoadEventHandler);
};
}, [iframeLoaded, iframeOnLoadEventHandler]);
}, [iframeLoaded, disabled, iframeOnLoadEventHandler]);
useEffect(() => {
if (!iframeLoaded) {
@ -122,7 +146,8 @@ function SignInExperiencePreview({
<div
className={classNames(
styles.preview,
platform === PreviewPlatform.DesktopWeb ? styles.web : styles.mobile
platform === PreviewPlatform.DesktopWeb ? styles.web : styles.mobile,
disabled && styles.disabled
)}
style={conditional(
platform === PreviewPlatform.DesktopWeb && {
@ -131,24 +156,33 @@ function SignInExperiencePreview({
}
)}
>
<div className={styles.deviceWrapper}>
<div className={classNames(styles.device, styles[String(mode)])}>
{platform !== PreviewPlatform.DesktopWeb && (
<div className={styles.topBar}>
<div className={styles.time}>{format(Date.now(), 'HH:mm')}</div>
<PhoneInfo />
</div>
)}
<iframe
ref={previewRef}
// Allow all sandbox rules
sandbox={undefined}
src={new URL('/sign-in?preview=true', endpoint).toString()}
tabIndex={-1}
title={t('sign_in_exp.preview.title')}
/>
{disabled ? (
<div className={styles.placeholder}>
<div className={styles.title}>{t('sign_in_exp.custom_ui.bring_your_ui_title')}</div>
<div className={styles.description}>
{t('sign_in_exp.custom_ui.preview_with_bring_your_ui_description')}
</div>
</div>
</div>
) : (
<div className={styles.deviceWrapper}>
<div className={classNames(styles.device, styles[String(mode)])}>
{platform !== PreviewPlatform.DesktopWeb && (
<div className={styles.topBar}>
<div className={styles.time}>{format(Date.now(), 'HH:mm')}</div>
<PhoneInfo />
</div>
)}
<iframe
ref={previewRef}
// Allow all sandbox rules
sandbox={undefined}
src={new URL('/sign-in?preview=true', endpoint).toString()}
tabIndex={-1}
title={t('sign_in_exp.preview.title')}
/>
</div>
</div>
)}
</div>
);
}

View file

@ -1,17 +0,0 @@
import type { AllowedUploadMimeType } from '@logto/schemas';
type MimeTypeToFileExtensionMappings = {
[key in AllowedUploadMimeType]: readonly string[];
};
export const mimeTypeToFileExtensionMappings: MimeTypeToFileExtensionMappings = Object.freeze({
'image/jpeg': ['jpeg', 'jpg'],
'image/png': ['png'],
'image/gif': ['gif'],
'image/vnd.microsoft.icon': ['ico'],
'image/x-icon': ['ico'],
'image/svg+xml': ['svg'],
'image/tiff': ['tif', 'tiff'],
'image/webp': ['webp'],
'image/bmp': ['bmp'],
} as const);

View file

@ -14,12 +14,12 @@ import { Ring } from '../../Spinner';
import * as styles from './index.module.scss';
export type Props = {
export type Props<T extends Record<string, unknown> = UserAssets> = {
readonly maxSize: number; // In bytes
readonly allowedMimeTypes: AllowedUploadMimeType[];
readonly actionDescription?: string;
readonly onCompleted: (fileUrl: string) => void;
readonly onUploadErrorChange: (errorMessage?: string) => void;
readonly onCompleted: (response: T) => void;
readonly onUploadErrorChange: (errorMessage?: string, files?: File[]) => void;
readonly className?: string;
/**
* Specify which API instance to use for the upload request. For example, you can use admin tenant API instead.
@ -32,7 +32,7 @@ export type Props = {
readonly uploadUrl?: string;
};
function FileUploader({
function FileUploader<T extends Record<string, unknown> = UserAssets>({
maxSize,
allowedMimeTypes,
actionDescription,
@ -41,7 +41,7 @@ function FileUploader({
className,
apiInstance,
uploadUrl = 'api/user-assets',
}: Props) {
}: Props<T>) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string>();
@ -60,7 +60,8 @@ function FileUploader({
async (acceptedFiles: File[] = [], fileRejection: FileRejection[] = []) => {
setUploadError(undefined);
if (fileRejection.length > 1) {
// Do not support uploading multiple files
if (acceptedFiles.length + fileRejection.length > 1) {
setUploadError(t('components.uploader.error_file_count'));
return;
@ -104,9 +105,9 @@ function FileUploader({
try {
setIsUploading(true);
const uploadApi = apiInstance ?? api;
const { url } = await uploadApi.post(uploadUrl, { body: formData }).json<UserAssets>();
const response = await uploadApi.post(uploadUrl, { body: formData }).json<T>();
onCompleted(url);
onCompleted(response);
} catch {
setUploadError(t('components.uploader.error_upload'));
} finally {

View file

@ -23,7 +23,9 @@ function ImageUploaderField({ onChange, allowedMimeTypes: mimeTypes, ...rest }:
<div>
<ImageUploader
allowedMimeTypes={allowedMimeTypes}
onCompleted={onChange}
onCompleted={({ url }) => {
onChange(url);
}}
onUploadErrorChange={setUploadError}
onDelete={() => {
onChange('');

View file

@ -44,7 +44,9 @@ function LogosUploader({ isDarkModeEnabled }: Props) {
: 'enterprise_sso_details.branding_logo_context'
)}
allowedMimeTypes={allowedMimeTypes}
onCompleted={onChange}
onCompleted={({ url }) => {
onChange(url);
}}
onUploadErrorChange={setUploadLogoError}
onDelete={() => {
onChange('');
@ -65,7 +67,9 @@ function LogosUploader({ isDarkModeEnabled }: Props) {
value={value ?? ''}
actionDescription={t('enterprise_sso_details.branding_dark_logo_context')}
allowedMimeTypes={allowedMimeTypes}
onCompleted={onChange}
onCompleted={({ url }) => {
onChange(url);
}}
onUploadErrorChange={setUploadDarkLogoError}
onDelete={() => {
onChange('');

View file

@ -1,6 +1,8 @@
import { useFormContext, Controller } from 'react-hook-form';
import { Controller, useFormContext } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import CustomUiAssetsUploader from '@/components/CustomUiAssetsUploader';
import { isDevFeaturesEnabled } from '@/consts/env';
import Card from '@/ds-components/Card';
import CodeEditor from '@/ds-components/CodeEditor';
import FormField from '@/ds-components/FormField';
@ -12,19 +14,19 @@ import FormSectionTitle from '../../components/FormSectionTitle';
import * as brandingStyles from './index.module.scss';
function CustomCssForm() {
function CustomUiForm() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { getDocumentationUrl } = useDocumentationUrl();
const { control } = useFormContext<SignInExperienceForm>();
return (
<Card>
<FormSectionTitle title="custom_css.title" />
<FormSectionTitle title="custom_ui.title" />
<FormField
title="sign_in_exp.custom_css.css_code_editor_title"
title="sign_in_exp.custom_ui.css_code_editor_title"
tip={(closeTipHandler) => (
<>
<div>{t('sign_in_exp.custom_css.css_code_editor_description1')}</div>
<div>{t('sign_in_exp.custom_ui.css_code_editor_description1')}</div>
<div>
<Trans
components={{
@ -37,8 +39,8 @@ function CustomCssForm() {
),
}}
>
{t('sign_in_exp.custom_css.css_code_editor_description2', {
link: t('sign_in_exp.custom_css.css_code_editor_description_link_content'),
{t('sign_in_exp.custom_ui.css_code_editor_description2', {
link: t('sign_in_exp.custom_ui.css_code_editor_description_link_content'),
})}
</Trans>
</div>
@ -53,14 +55,42 @@ function CustomCssForm() {
className={brandingStyles.customCssCodeEditor}
language="scss"
value={value ?? undefined}
placeholder={t('sign_in_exp.custom_css.css_code_editor_content_placeholder')}
placeholder={t('sign_in_exp.custom_ui.css_code_editor_content_placeholder')}
onChange={onChange}
/>
)}
/>
</FormField>
{isDevFeaturesEnabled && (
<FormField
title="sign_in_exp.custom_ui.bring_your_ui_title"
description={
<Trans
components={{
a: (
<TextLink
targetBlank="noopener"
href={getDocumentationUrl('/docs/recipes/customize-sie/custom-css')}
/>
),
}}
>
{t('sign_in_exp.custom_ui.bring_your_ui_description')}
</Trans>
}
descriptionPosition="top"
>
<Controller
name="customUiAssets"
control={control}
render={({ field: { onChange, value } }) => (
<CustomUiAssetsUploader value={value} onChange={onChange} />
)}
/>
</FormField>
)}
</Card>
);
}
export default CustomCssForm;
export default CustomUiForm;

View file

@ -3,7 +3,7 @@ import PageMeta from '@/components/PageMeta';
import SignInExperienceTabWrapper from '../components/SignInExperienceTabWrapper';
import BrandingForm from './BrandingForm';
import CustomCssForm from './CustomCssForm';
import CustomUiForm from './CustomUiForm';
type Props = {
readonly isActive: boolean;
@ -14,7 +14,7 @@ function Branding({ isActive }: Props) {
<SignInExperienceTabWrapper isActive={isActive}>
{isActive && <PageMeta titleKey={['sign_in_exp.tabs.branding', 'sign_in_exp.page_title']} />}
<BrandingForm />
<CustomCssForm />
<CustomUiForm />
</SignInExperienceTabWrapper>
);
}

View file

@ -137,6 +137,7 @@ function PageContent({ data, onSignInExperienceUpdated }: Props) {
<Preview
isLivePreviewDisabled={isDirty}
signInExperience={previewConfigs}
isPreviewIframeDisabled={Boolean(data.customUiAssets)}
className={styles.preview}
/>
)}

View file

@ -49,13 +49,14 @@ export const signUpFormDataParser = {
export const sieFormDataParser = {
fromSignInExperience: (data: SignInExperience): SignInExperienceForm => {
const { signUp, signInMode, customCss, branding, passwordPolicy } = data;
const { signUp, signInMode, customCss, customUiAssets, branding, passwordPolicy } = data;
return {
...data,
signUp: signUpFormDataParser.fromSignUp(signUp),
createAccountEnabled: signInMode !== SignInMode.SignIn,
customCss: customCss ?? undefined,
customUiAssets: customUiAssets ?? undefined,
branding: {
...emptyBranding,
...branding,
@ -74,6 +75,7 @@ export const sieFormDataParser = {
createAccountEnabled,
signUp,
customCss,
customUiAssets,
/** Remove the custom words related properties since they are not used in the remote model. */
passwordPolicy: { isCustomWordsEnabled, customWords, ...passwordPolicy },
} = formData;
@ -84,6 +86,7 @@ export const sieFormDataParser = {
signUp: signUpFormDataParser.toSignUp(signUp),
signInMode: createAccountEnabled ? SignInMode.SignInAndRegister : SignInMode.SignIn,
customCss: customCss?.length ? customCss : null,
customUiAssets: customUiAssets?.id ? customUiAssets : null,
passwordPolicy: {
...passwordPolicy,
rejects: {

View file

@ -18,6 +18,7 @@ import * as styles from './index.module.scss';
type Props = {
readonly isLivePreviewDisabled?: boolean;
readonly isLivePreviewEntryInvisible?: boolean;
readonly isPreviewIframeDisabled?: boolean;
readonly signInExperience?: SignInExperience;
readonly className?: string;
};
@ -25,6 +26,7 @@ type Props = {
function Preview({
isLivePreviewDisabled = false,
isLivePreviewEntryInvisible = false,
isPreviewIframeDisabled = false,
signInExperience,
className,
}: Props) {
@ -124,6 +126,7 @@ function Preview({
mode={mode}
language={language}
signInExperience={signInExperience}
disabled={isPreviewIframeDisabled}
/>
</div>
);

View file

@ -1,5 +1,10 @@
import { type PasswordPolicy } from '@logto/core-kit';
import { type SignUp, type SignInExperience, type SignInIdentifier } from '@logto/schemas';
import {
type SignUp,
type SignInExperience,
type SignInIdentifier,
type CustomUiAssets,
} from '@logto/schemas';
export enum SignInExperienceTab {
Branding = 'branding',
@ -22,9 +27,10 @@ export type SignUpForm = Omit<SignUp, 'identifiers'> & {
export type SignInExperienceForm = Omit<
SignInExperience,
'signUp' | 'customCss' | 'passwordPolicy'
'signUp' | 'customCss' | 'customUiAssets' | 'passwordPolicy'
> & {
customCss?: string; // Code editor components can not properly handle null value, manually transform null to undefined instead.
customUiAssets?: CustomUiAssets;
signUp: SignUpForm;
/** The parsed password policy object. All properties are required. */
passwordPolicy: PasswordPolicy & {

View file

@ -1,11 +1,24 @@
import type { AllowedUploadMimeType } from '@logto/schemas';
import { mimeTypeToFileExtensionMappings, type AllowedUploadMimeType } from '@logto/schemas';
import { deduplicate } from '@silverhand/essentials';
import { mimeTypeToFileExtensionMappings } from '@/consts/user-assets';
export const convertToFileExtensionArray = (mimeTypes: AllowedUploadMimeType[]) =>
deduplicate(
mimeTypes
.flatMap((type) => mimeTypeToFileExtensionMappings[type])
.map((extension) => extension.toUpperCase())
);
// https://stackoverflow.com/a/18650828/12514940
export const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) {
return '0 Bytes';
}
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Number.parseFloat((bytes / k ** i).toFixed(dm)) + ' ' + sizes[i];
};

View file

@ -70,14 +70,19 @@ const sign_in_exp = {
error: 'Favicon: {{error}}',
},
},
custom_css: {
title: 'Custom CSS',
css_code_editor_title: 'Personalize your UI with custom CSS',
custom_ui: {
title: 'Custom UI',
css_code_editor_title: 'Custom CSS',
css_code_editor_description1: 'See the example of custom CSS.',
css_code_editor_description2: '<a>{{link}}</a>',
css_code_editor_description_link_content: 'Learn more',
css_code_editor_content_placeholder:
'Enter your custom CSS to tailor the styles of anything to your exact specifications. Express your creativity and make your UI stand out.',
bring_your_ui_title: 'Bring your UI',
bring_your_ui_description:
'Upload a compressed package (.zip) and replace the Logto prebuilt UI with your own code. <a>Learn more</a>',
preview_with_bring_your_ui_description:
'Your custom UI assets have been successfully uploaded and are now being served. Consequently, the built-in preview window has been disabled.\nTo test your personalized sign-in UI, click the "Live Preview" button to open it in a new browser tab.',
},
sign_up_and_sign_in,
content,

View file

@ -13,6 +13,7 @@ export const allowUploadMimeTypes = [
'image/tiff',
'image/webp',
'image/bmp',
'application/zip',
] as const;
const allowUploadMimeTypeGuard = z.enum(allowUploadMimeTypes);
@ -39,3 +40,20 @@ export const uploadFileGuard = z.object({
originalFilename: z.string(),
size: z.number(),
});
type MimeTypeToFileExtensionMappings = {
[key in AllowedUploadMimeType]: readonly string[];
};
export const mimeTypeToFileExtensionMappings: MimeTypeToFileExtensionMappings = Object.freeze({
'image/jpeg': ['jpeg', 'jpg'],
'image/png': ['png'],
'image/gif': ['gif'],
'image/vnd.microsoft.icon': ['ico'],
'image/x-icon': ['ico'],
'image/svg+xml': ['svg'],
'image/tiff': ['tif', 'tiff'],
'image/webp': ['webp'],
'image/bmp': ['bmp'],
'application/zip': ['zip'],
} as const);