-
-
- {platform !== PreviewPlatform.DesktopWeb && (
-
-
{format(Date.now(), 'HH:mm')}
-
-
- )}
-
+ {disabled ? (
+
+
{t('sign_in_exp.custom_ui.bring_your_ui_title')}
+
+ {t('sign_in_exp.custom_ui.preview_with_bring_your_ui_description')}
+
-
+ ) : (
+
+
+ {platform !== PreviewPlatform.DesktopWeb && (
+
+
{format(Date.now(), 'HH:mm')}
+
+
+ )}
+
+
+
+ )}
);
}
diff --git a/packages/console/src/consts/user-assets.ts b/packages/console/src/consts/user-assets.ts
deleted file mode 100644
index 2ecbdb524..000000000
--- a/packages/console/src/consts/user-assets.ts
+++ /dev/null
@@ -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);
diff --git a/packages/console/src/ds-components/Uploader/FileUploader/index.tsx b/packages/console/src/ds-components/Uploader/FileUploader/index.tsx
index edafb2d32..35d4383de 100644
--- a/packages/console/src/ds-components/Uploader/FileUploader/index.tsx
+++ b/packages/console/src/ds-components/Uploader/FileUploader/index.tsx
@@ -14,12 +14,12 @@ import { Ring } from '../../Spinner';
import * as styles from './index.module.scss';
-export type Props = {
+export type Props
= 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 = UserAssets>({
maxSize,
allowedMimeTypes,
actionDescription,
@@ -41,7 +41,7 @@ function FileUploader({
className,
apiInstance,
uploadUrl = 'api/user-assets',
-}: Props) {
+}: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState();
@@ -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();
+ const response = await uploadApi.post(uploadUrl, { body: formData }).json();
- onCompleted(url);
+ onCompleted(response);
} catch {
setUploadError(t('components.uploader.error_upload'));
} finally {
diff --git a/packages/console/src/ds-components/Uploader/ImageUploaderField/index.tsx b/packages/console/src/ds-components/Uploader/ImageUploaderField/index.tsx
index faaccc9ce..2b519a7f4 100644
--- a/packages/console/src/ds-components/Uploader/ImageUploaderField/index.tsx
+++ b/packages/console/src/ds-components/Uploader/ImageUploaderField/index.tsx
@@ -23,7 +23,9 @@ function ImageUploaderField({ onChange, allowedMimeTypes: mimeTypes, ...rest }:
{
+ onChange(url);
+ }}
onUploadErrorChange={setUploadError}
onDelete={() => {
onChange('');
diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Experience/LogosUploader/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Experience/LogosUploader/index.tsx
index e9bfc1c6a..6bdb2ffad 100644
--- a/packages/console/src/pages/EnterpriseSsoDetails/Experience/LogosUploader/index.tsx
+++ b/packages/console/src/pages/EnterpriseSsoDetails/Experience/LogosUploader/index.tsx
@@ -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('');
diff --git a/packages/console/src/pages/SignInExperience/PageContent/Branding/CustomCssForm/index.module.scss b/packages/console/src/pages/SignInExperience/PageContent/Branding/CustomUiForm/index.module.scss
similarity index 100%
rename from packages/console/src/pages/SignInExperience/PageContent/Branding/CustomCssForm/index.module.scss
rename to packages/console/src/pages/SignInExperience/PageContent/Branding/CustomUiForm/index.module.scss
diff --git a/packages/console/src/pages/SignInExperience/PageContent/Branding/CustomCssForm/index.tsx b/packages/console/src/pages/SignInExperience/PageContent/Branding/CustomUiForm/index.tsx
similarity index 51%
rename from packages/console/src/pages/SignInExperience/PageContent/Branding/CustomCssForm/index.tsx
rename to packages/console/src/pages/SignInExperience/PageContent/Branding/CustomUiForm/index.tsx
index a25929b17..a5b82f6d0 100644
--- a/packages/console/src/pages/SignInExperience/PageContent/Branding/CustomCssForm/index.tsx
+++ b/packages/console/src/pages/SignInExperience/PageContent/Branding/CustomUiForm/index.tsx
@@ -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();
return (
-
+
(
<>
- {t('sign_in_exp.custom_css.css_code_editor_description1')}
+ {t('sign_in_exp.custom_ui.css_code_editor_description1')}
- {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'),
})}
@@ -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}
/>
)}
/>
+ {isDevFeaturesEnabled && (
+
+ ),
+ }}
+ >
+ {t('sign_in_exp.custom_ui.bring_your_ui_description')}
+
+ }
+ descriptionPosition="top"
+ >
+ (
+
+ )}
+ />
+
+ )}
);
}
-export default CustomCssForm;
+export default CustomUiForm;
diff --git a/packages/console/src/pages/SignInExperience/PageContent/Branding/index.tsx b/packages/console/src/pages/SignInExperience/PageContent/Branding/index.tsx
index 4a11479db..c300df3ed 100644
--- a/packages/console/src/pages/SignInExperience/PageContent/Branding/index.tsx
+++ b/packages/console/src/pages/SignInExperience/PageContent/Branding/index.tsx
@@ -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) {
{isActive && }
-
+
);
}
diff --git a/packages/console/src/pages/SignInExperience/PageContent/index.tsx b/packages/console/src/pages/SignInExperience/PageContent/index.tsx
index c119c2254..32eebcad0 100644
--- a/packages/console/src/pages/SignInExperience/PageContent/index.tsx
+++ b/packages/console/src/pages/SignInExperience/PageContent/index.tsx
@@ -137,6 +137,7 @@ function PageContent({ data, onSignInExperienceUpdated }: Props) {
)}
diff --git a/packages/console/src/pages/SignInExperience/PageContent/utils/parser.ts b/packages/console/src/pages/SignInExperience/PageContent/utils/parser.ts
index b7b1a255a..7ccdca808 100644
--- a/packages/console/src/pages/SignInExperience/PageContent/utils/parser.ts
+++ b/packages/console/src/pages/SignInExperience/PageContent/utils/parser.ts
@@ -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: {
diff --git a/packages/console/src/pages/SignInExperience/components/Preview/index.tsx b/packages/console/src/pages/SignInExperience/components/Preview/index.tsx
index 43fc61892..9dfecb51d 100644
--- a/packages/console/src/pages/SignInExperience/components/Preview/index.tsx
+++ b/packages/console/src/pages/SignInExperience/components/Preview/index.tsx
@@ -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}
/>
);
diff --git a/packages/console/src/pages/SignInExperience/types.ts b/packages/console/src/pages/SignInExperience/types.ts
index 0a3a1a943..65d4bf000 100644
--- a/packages/console/src/pages/SignInExperience/types.ts
+++ b/packages/console/src/pages/SignInExperience/types.ts
@@ -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 & {
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 & {
diff --git a/packages/console/src/utils/uploader.ts b/packages/console/src/utils/uploader.ts
index c4cb161e4..e17d1640b 100644
--- a/packages/console/src/utils/uploader.ts
+++ b/packages/console/src/utils/uploader.ts
@@ -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];
+};
diff --git a/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/index.ts b/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/index.ts
index e2b468feb..26c3925a1 100644
--- a/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/index.ts
+++ b/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/index.ts
@@ -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: '{{link}}',
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. Learn more',
+ 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,
diff --git a/packages/schemas/src/types/user-assets.ts b/packages/schemas/src/types/user-assets.ts
index 99e92bc8f..931e29480 100644
--- a/packages/schemas/src/types/user-assets.ts
+++ b/packages/schemas/src/types/user-assets.ts
@@ -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);