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:
parent
6963192ac5
commit
17d7be3992
21 changed files with 471 additions and 69 deletions
102
packages/console/src/assets/images/blur-preview.svg
Normal file
102
packages/console/src/assets/images/blur-preview.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 82 KiB |
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
20
packages/console/src/components/FileIcon/index.tsx
Normal file
20
packages/console/src/components/FileIcon/index.tsx
Normal 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;
|
|
@ -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={() => {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
|
@ -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 {
|
||||
|
|
|
@ -23,7 +23,9 @@ function ImageUploaderField({ onChange, allowedMimeTypes: mimeTypes, ...rest }:
|
|||
<div>
|
||||
<ImageUploader
|
||||
allowedMimeTypes={allowedMimeTypes}
|
||||
onCompleted={onChange}
|
||||
onCompleted={({ url }) => {
|
||||
onChange(url);
|
||||
}}
|
||||
onUploadErrorChange={setUploadError}
|
||||
onDelete={() => {
|
||||
onChange('');
|
||||
|
|
|
@ -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('');
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -137,6 +137,7 @@ function PageContent({ data, onSignInExperienceUpdated }: Props) {
|
|||
<Preview
|
||||
isLivePreviewDisabled={isDirty}
|
||||
signInExperience={previewConfigs}
|
||||
isPreviewIframeDisabled={Boolean(data.customUiAssets)}
|
||||
className={styles.preview}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 & {
|
||||
|
|
|
@ -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];
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue