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

Merge pull request #6206 from logto-io/gao-refactor-logo-uploads

refactor(console): reorg logo uploads
This commit is contained in:
Gao Sun 2024-07-10 14:47:48 +08:00 committed by GitHub
commit 6060919a21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 409 additions and 630 deletions

View file

@ -15,13 +15,3 @@
.text + .text {
margin-top: _.unit(2);
}
.field {
@include _.shimmering-animation;
width: 100%;
height: 44px;
}
.field + .field {
margin-top: _.unit(6);
}

View file

@ -1,3 +1,5 @@
import FormFieldSkeleton from '@/ds-components/FormField/Skeleton';
import FormCardLayout from '../FormCardLayout';
import * as styles from './index.module.scss';
@ -21,10 +23,7 @@ function Skeleton({ formFieldCount = 4 }: Props) {
</>
}
>
{Array.from({ length: formFieldCount }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={index} className={styles.field} />
))}
<FormFieldSkeleton formFieldCount={formFieldCount} />
</FormCardLayout>
);
}

View file

@ -0,0 +1,62 @@
import { type Theme } from '@logto/schemas';
import { type FieldValues, type Control, type UseFormRegister } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import ImageInputs, { type ImageField } from '.';
type Field<FormContext extends FieldValues> = Pick<ImageField<FormContext>, 'name' | 'error'>;
type Props<FormContext extends FieldValues> = {
readonly theme: Theme;
readonly control: Control<FormContext>;
readonly register: UseFormRegister<FormContext>;
/**
* Form-related data of the logo input, including the name (field path) and error in the form.
*/
readonly logo: Field<FormContext>;
/**
* Form-related data of the favicon input, including the name (field path) and error in the form.
*/
readonly favicon: Field<FormContext>;
/** The type of the logo. It will affect the translation key. */
readonly type: 'app_logo' | 'company_logo';
};
/**
* A component that renders the logo and favicon inputs for a form.
*
* When user assets service is available, it will render two image uploader components side-by-side;
* otherwise, it will render two text inputs.
*
* @see {@link ImageInputs} for the implementation of the inner components.
*/
function LogoAndFavicon<FormContext extends FieldValues>({
theme,
control,
register,
logo,
favicon,
type,
}: Props<FormContext>) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<ImageInputs
uploadTitle={
<>
{t(`sign_in_exp.branding.with_${theme}`, {
value: t('sign_in_exp.branding.app_logo_and_favicon'),
})}
</>
}
control={control}
register={register}
fields={[
{ ...logo, theme, type },
{ ...favicon, theme, type: 'favicon' },
]}
/>
);
}
export default LogoAndFavicon;

View file

@ -6,20 +6,27 @@
> * {
flex: 1;
// remove the left side of the border-radius on the second column when dark mode logo uploader is active
&:not(:first-child) {
&:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
&:last-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
&.darkMode {
background-color: #000;
&:not(:first-child):not(:last-child) {
border-radius: 0;
}
&.dark {
background-color: #111;
}
}
.multiColumn {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
.logo {
flex: 3;
}
}

View file

@ -0,0 +1,155 @@
import { type LocalePhrase } from '@logto/phrases';
import { type Theme } from '@logto/schemas';
import { cond, noop } from '@silverhand/essentials';
import classNames from 'classnames';
import type React from 'react';
import { useMemo, useState } from 'react';
import {
Controller,
type FieldPath,
type FieldValues,
type Control,
type UseFormRegister,
type FieldError,
} from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import FormField from '@/ds-components/FormField';
import Skeleton from '@/ds-components/FormField/Skeleton';
import TextInput from '@/ds-components/TextInput';
import ImageUploader from '@/ds-components/Uploader/ImageUploader';
import useImageMimeTypes from '@/hooks/use-image-mime-types';
import useUserAssetsService from '@/hooks/use-user-assets-service';
import { uriValidator } from '@/utils/validator';
import * as styles from './index.module.scss';
export type ImageField<FormContext extends FieldValues> = {
/** The name (field path) of the field in the form. */
name: FieldPath<FormContext>;
/**
* The type of the field. It should match the existing structure in the translation file to get
* the correct translations.
*/
type: keyof LocalePhrase['translation']['admin_console']['sign_in_exp']['branding_uploads'];
theme: Theme;
/** The error message of the field in the form. */
error?: FieldError;
};
type Props<FormContext extends FieldValues> = {
/** The condensed title when user assets service is available. */
readonly uploadTitle: React.ComponentProps<typeof FormField>['title'];
readonly control: Control<FormContext>;
readonly register: UseFormRegister<FormContext>;
readonly fields: Array<ImageField<FormContext>>;
};
/**
* A component that renders the logo inputs for a form.
*
* When user assets service is available, it will render the image uploader components side-by-side;
* otherwise, it will render the text inputs.
*/
function ImageInputs<FormContext extends FieldValues>({
uploadTitle,
control,
register,
fields,
}: Props<FormContext>) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [uploadErrors, setUploadErrors] = useState<Partial<Record<string, string>>>({});
const { description } = useImageMimeTypes();
const { isReady: isUserAssetsServiceReady, isLoading } = useUserAssetsService();
const uploadErrorChangeHandlers = useMemo(
() =>
Object.fromEntries(
fields.map((field) => [
field.name,
(message?: string) => {
setUploadErrors((previous) => ({ ...previous, [field.name]: message }));
},
])
),
[fields]
);
if (isLoading) {
return <Skeleton formFieldCount={2} />;
}
if (!isUserAssetsServiceReady) {
return (
<>
{fields.map((field) => (
<FormField
key={field.name}
title={
<>
{t(`sign_in_exp.branding.with_${field.theme}`, {
value: t(`sign_in_exp.branding_uploads.${field.type}.url`),
})}
</>
}
>
<TextInput
key={field.name}
{...register(field.name, {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
shouldUnregister: true,
})}
placeholder={t(`sign_in_exp.branding_uploads.${field.type}.url_placeholder`)}
error={field.error?.message}
/>
</FormField>
))}
</>
);
}
return (
<FormField title={uploadTitle}>
<div className={styles.container}>
{fields.map((field) => (
<Controller
key={field.name}
name={field.name}
control={control}
render={({ field: { onChange, value, name } }) => (
<ImageUploader
className={cond(field.type.endsWith('_logo') && styles.logo)}
uploadedClassName={styles[field.theme]}
name={name}
value={value ?? ''}
actionDescription={t(`sign_in_exp.branding.with_${field.theme}`, {
value: t(`sign_in_exp.branding_uploads.${field.type}.title`),
})}
onCompleted={onChange}
// Noop fallback should not be necessary, but for TypeScript to be happy
onUploadErrorChange={uploadErrorChangeHandlers[field.name] ?? noop}
onDelete={() => {
onChange('');
}}
/>
)}
/>
))}
</div>
{fields.map(
(field) =>
uploadErrors[field.name] && (
<div key={field.name} className={classNames(styles.description, styles.error)}>
{t(`sign_in_exp.branding_uploads.${field.type}.error`, {
error: uploadErrors[field.name],
})}
</div>
)
)}
<div className={styles.description}>{description}</div>
</FormField>
);
}
export default ImageInputs;

View file

@ -0,0 +1,11 @@
@use '@/scss/underscore' as _;
.field {
@include _.shimmering-animation;
width: 100%;
height: 44px;
}
.field + .field {
margin-top: _.unit(6);
}

View file

@ -0,0 +1,18 @@
import * as styles from './Skeleton.module.scss';
type Props = {
readonly formFieldCount: number;
};
function Skeleton({ formFieldCount }: Props) {
return (
<>
{Array.from({ length: formFieldCount }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={index} className={styles.field} />
))}
</>
);
}
export default Skeleton;

View file

@ -17,6 +17,7 @@ export type Props = Omit<FileUploaderProps, 'maxSize' | 'allowedMimeTypes'> & {
readonly value: string;
readonly onDelete: () => void;
readonly className?: string;
readonly uploadedClassName?: string;
};
function ImageUploader({
@ -25,11 +26,12 @@ function ImageUploader({
onDelete,
allowedMimeTypes: imageMimeTypes,
className,
uploadedClassName,
...rest
}: Props) {
const { allowedMimeTypes } = useImageMimeTypes(imageMimeTypes);
return value ? (
<div className={classNames(styles.imageUploader, className)}>
<div className={classNames(styles.imageUploader, className, uploadedClassName)}>
<ImageWithErrorFallback
containerClassName={styles.container}
src={value}

View file

@ -1,79 +0,0 @@
import { type ApplicationSignInExperience } from '@logto/schemas';
import classNames from 'classnames';
import { useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import ImageUploader from '@/ds-components/Uploader/ImageUploader';
import useImageMimeTypes from '@/hooks/use-image-mime-types';
import * as styles from './LogoUploader.module.scss';
type Props = {
readonly isDarkModeEnabled?: boolean;
};
function LogoUploader({ isDarkModeEnabled }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [uploadLogoError, setUploadLogoError] = useState<string>();
const [uploadDarkLogoError, setUploadDarkLogoError] = useState<string>();
const { description } = useImageMimeTypes();
const { control } = useFormContext<ApplicationSignInExperience>();
return (
<div>
<div className={styles.container}>
<Controller
name="branding.logoUrl"
control={control}
render={({ field: { onChange, value, name } }) => (
<ImageUploader
className={isDarkModeEnabled ? styles.multiColumn : undefined}
name={name}
value={value ?? ''}
actionDescription={t('sign_in_exp.branding.logo_image')}
onCompleted={onChange}
onUploadErrorChange={setUploadLogoError}
onDelete={() => {
onChange('');
}}
/>
)}
/>
{/* Show the dark mode logto uploader only if dark mode is enabled in the global sign-in-experience */}
{isDarkModeEnabled && (
<Controller
name="branding.darkLogoUrl"
control={control}
render={({ field: { onChange, value, name } }) => (
<ImageUploader
name={name}
value={value ?? ''}
className={value ? styles.darkMode : undefined}
actionDescription={t('sign_in_exp.branding.dark_logo_image')}
onCompleted={onChange}
onUploadErrorChange={setUploadDarkLogoError}
onDelete={() => {
onChange('');
}}
/>
)}
/>
)}
</div>
{uploadLogoError && (
<div className={classNames(styles.description, styles.error)}>
{t('sign_in_exp.branding.logo_image_error', { error: uploadLogoError })}
</div>
)}
{uploadDarkLogoError && (
<div className={classNames(styles.description, styles.error)}>
{t('sign_in_exp.branding.logo_image_error', { error: uploadDarkLogoError })}
</div>
)}
<div className={styles.description}>{description}</div>
</div>
);
}
export default LogoUploader;

View file

@ -1,4 +1,4 @@
import { type Application, type ApplicationSignInExperience } from '@logto/schemas';
import { Theme, type Application, type ApplicationSignInExperience } from '@logto/schemas';
import { useCallback, useEffect } from 'react';
import { useForm, FormProvider, Controller } from 'react-hook-form';
import { toast } from 'react-hot-toast';
@ -6,24 +6,22 @@ import { useTranslation } from 'react-i18next';
import DetailsForm from '@/components/DetailsForm';
import FormCard, { FormCardSkeleton } from '@/components/FormCard';
import LogoAndFavicon from '@/components/ImageInputs/LogoAndFavicon';
import RequestDataError from '@/components/RequestDataError';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { logtoThirdPartyAppBrandingLink } from '@/consts';
import Checkbox from '@/ds-components/Checkbox';
import ColorPicker from '@/ds-components/ColorPicker';
import FormField from '@/ds-components/FormField';
import TextInput from '@/ds-components/TextInput';
import useApi from '@/hooks/use-api';
import useDocumentationUrl from '@/hooks/use-documentation-url';
import useUserAssetsService from '@/hooks/use-user-assets-service';
import { trySubmitSafe } from '@/utils/form';
import { uriValidator } from '@/utils/validator';
import LogoUploader from './LogoUploader';
import * as styles from './index.module.scss';
import useApplicationSignInExperienceSWR from './use-application-sign-in-experience-swr';
import useSignInExperienceSWR from './use-sign-in-experience-swr';
import { formatFormToSubmitData, formatResponseDataToForm } from './utils';
import { formatFormToSubmitData } from './utils';
type Props = {
readonly application: Application;
@ -47,8 +45,6 @@ function Branding({ application, isActive }: Props) {
handleSubmit,
register,
reset,
setValue,
watch,
control,
formState: { isDirty, isSubmitting, errors },
} = formMethods;
@ -57,14 +53,10 @@ function Branding({ application, isActive }: Props) {
const { data, error, mutate } = useApplicationSignInExperienceSWR(application.id);
const { data: sieData, error: sieError, mutate: sieMutate } = useSignInExperienceSWR();
const { isReady: isUserAssetsServiceReady, isLoading: isUserAssetsServiceLoading } =
useUserAssetsService();
const isApplicationSieLoading = !data && !error;
const isSieLoading = !sieData && !sieError;
const isLoading = isApplicationSieLoading || isSieLoading || isUserAssetsServiceLoading;
const color = watch('color');
const isColorEmpty = !color.primaryColor && !color.darkPrimaryColor;
const isLoading = isApplicationSieLoading || isSieLoading;
const onSubmit = handleSubmit(
trySubmitSafe(async (data) => {
@ -93,7 +85,7 @@ function Branding({ application, isActive }: Props) {
return;
}
reset(formatResponseDataToForm(data));
reset(data);
}, [data, reset]);
if (isLoading) {
@ -104,8 +96,6 @@ function Branding({ application, isActive }: Props) {
return <RequestDataError error={error} onRetry={onRetryFetch} />;
}
const isDarkModeEnabled = sieData?.color.isDarkModeEnabled;
return (
<>
<FormProvider {...formMethods}>
@ -134,77 +124,48 @@ function Branding({ application, isActive }: Props) {
<TextInput {...register('displayName')} placeholder={application.name} />
</FormField>
)}
{isUserAssetsServiceReady && (
<FormField title="application_details.branding.application_logo">
<LogoUploader isDarkModeEnabled={isDarkModeEnabled} />
</FormField>
)}
{/* Display the TextInput field if image upload service is not available */}
{!isUserAssetsServiceReady && (
<FormField title="application_details.branding.application_logo">
<TextInput
{...register('branding.logoUrl', {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
placeholder={t('sign_in_exp.branding.logo_image_url_placeholder')}
error={errors.branding?.logoUrl?.message}
/>
</FormField>
)}
{/* Display the Dark logo field only if the dark mode is enabled in the global sign-in-experience */}
{!isUserAssetsServiceReady && isDarkModeEnabled && (
<FormField title="application_details.branding.application_logo_dark">
<TextInput
{...register('branding.darkLogoUrl', {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
placeholder={t('sign_in_exp.branding.dark_logo_image_url_placeholder')}
error={errors.branding?.darkLogoUrl?.message}
/>
</FormField>
)}
<LogoAndFavicon
control={control}
register={register}
theme={Theme.Light}
type="app_logo"
logo={{ name: 'branding.logoUrl', error: errors.branding?.logoUrl }}
favicon={{
name: 'branding.favicon',
error: errors.branding?.favicon,
}}
/>
<LogoAndFavicon
control={control}
register={register}
theme={Theme.Dark}
type="app_logo"
logo={{ name: 'branding.darkLogoUrl', error: errors.branding?.darkLogoUrl }}
favicon={{
name: 'branding.darkFavicon',
error: errors.branding?.darkFavicon,
}}
/>
{!application.isThirdParty && (
<div className={styles.colors}>
<Checkbox
label={t('application_details.branding.use_different_brand_color')}
checked={!isColorEmpty}
onChange={(value) => {
setValue(
'color',
value
? {
primaryColor: '#ffffff',
darkPrimaryColor: '#000000',
}
: {},
{ shouldDirty: true }
);
}}
<Controller
control={control}
name="color.primaryColor"
render={({ field: { name, value, onChange } }) => (
<FormField title="application_details.branding.brand_color">
<ColorPicker name={name} value={value} onChange={onChange} />
</FormField>
)}
/>
<Controller
control={control}
name="color.darkPrimaryColor"
render={({ field: { name, value, onChange } }) => (
<FormField title="application_details.branding.brand_color_dark">
<ColorPicker name={name} value={value} onChange={onChange} />
</FormField>
)}
/>
{!isColorEmpty && (
<>
<Controller
control={control}
name="color.primaryColor"
render={({ field: { name, value, onChange } }) => (
<FormField title="application_details.branding.brand_color">
<ColorPicker name={name} value={value} onChange={onChange} />
</FormField>
)}
/>
<Controller
control={control}
name="color.darkPrimaryColor"
render={({ field: { name, value, onChange } }) => (
<FormField title="application_details.branding.brand_color_dark">
<ColorPicker name={name} value={value} onChange={onChange} />
</FormField>
)}
/>
</>
)}
</div>
)}
</FormCard>

View file

@ -1,5 +1,6 @@
import { type ApplicationSignInExperience } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { removeFalsyValues } from '@/utils/object';
/**
* Format the form data to match the API request body
@ -13,29 +14,6 @@ export const formatFormToSubmitData = (
return {
...rest,
branding: {
...conditional(branding.logoUrl && { logoUrl: branding.logoUrl }),
...conditional(branding.darkLogoUrl && { darkLogoUrl: branding.darkLogoUrl }),
},
};
};
/**
* Format the response data to match the form data
*
* Fulfill the branding object with empty string if the `logoUrl` or `darkLogoUrl` is not set.
* Otherwise, the RHF won't update the branding fields properly with the undefined value.
*/
export const formatResponseDataToForm = (
data: ApplicationSignInExperience
): ApplicationSignInExperience => {
const { branding, ...rest } = data;
return {
...rest,
branding: {
logoUrl: branding.logoUrl ?? '',
darkLogoUrl: branding.darkLogoUrl ?? '',
},
branding: removeFalsyValues(branding),
};
};

View file

@ -1,72 +0,0 @@
import { Theme, themeToLogoKey } from '@logto/schemas';
import { Controller, type UseFormReturn } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import FormCard, { FormCardSkeleton } from '@/components/FormCard';
import FormField from '@/ds-components/FormField';
import TextInput from '@/ds-components/TextInput';
import ImageUploaderField from '@/ds-components/Uploader/ImageUploaderField';
import useUserAssetsService from '@/hooks/use-user-assets-service';
import { uriValidator } from '@/utils/validator';
import * as styles from './index.module.scss';
import { type FormData } from './utils';
type Props = {
readonly form: UseFormReturn<FormData>;
};
function Branding({ form }: Props) {
const { isReady: isUserAssetsServiceReady, isLoading } = useUserAssetsService();
const {
control,
formState: { errors },
register,
} = form;
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
if (isLoading) {
return <FormCardSkeleton />;
}
return (
<FormCard
title="organization_details.branding.title"
description="organization_details.branding.description"
>
<div className={styles.branding}>
{Object.values(Theme).map((theme) => (
<section key={theme}>
<FormField title={`organization_details.branding.${theme}_logo`}>
{isUserAssetsServiceReady ? (
<Controller
control={control}
name={`branding.${themeToLogoKey[theme]}`}
render={({ field: { onChange, value, name } }) => (
<ImageUploaderField
name={name}
value={value ?? ''}
actionDescription={t('organization_details.branding.logo_upload_description')}
onChange={onChange}
/>
)}
/>
) : (
<TextInput
{...register(`branding.${themeToLogoKey[theme]}`, {
validate: (value?: string) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
error={errors.branding?.[themeToLogoKey[theme]]?.message}
placeholder={t('sign_in_exp.branding.logo_image_url_placeholder')}
/>
)}
</FormField>
</section>
))}
</div>
</FormCard>
);
}
export default Branding;

View file

@ -22,12 +22,6 @@
gap: _.unit(3);
}
.branding {
section + section {
margin-top: _.unit(6);
}
}
.mfaWarning {
margin-top: _.unit(3);
}

View file

@ -1,4 +1,4 @@
import { type SignInExperience, type Organization } from '@logto/schemas';
import { type SignInExperience, type Organization, Theme } from '@logto/schemas';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
@ -7,6 +7,7 @@ import useSWR from 'swr';
import DetailsForm from '@/components/DetailsForm';
import FormCard from '@/components/FormCard';
import LogoInputs from '@/components/ImageInputs';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import CodeEditor from '@/ds-components/CodeEditor';
import FormField from '@/ds-components/FormField';
@ -20,11 +21,15 @@ import { trySubmitSafe } from '@/utils/form';
import { type OrganizationDetailsOutletContext } from '../types';
import Branding from './Branding';
import JitSettings from './JitSettings';
import * as styles from './index.module.scss';
import { assembleData, isJsonObject, normalizeData, type FormData } from './utils';
const themeToLogoName = Object.freeze({
[Theme.Light]: 'logoUrl',
[Theme.Dark]: 'darkLogoUrl',
} as const satisfies Record<Theme, string>);
function Settings() {
const { isDeleting, data, jit, onUpdated } = useOutletContext<OrganizationDetailsOutletContext>();
const { data: signInExperience } = useSWR<SignInExperience, RequestError>('api/sign-in-exp');
@ -104,6 +109,17 @@ function Settings() {
{...register('description')}
/>
</FormField>
<LogoInputs
uploadTitle="organization_details.branding.logo"
control={control}
register={register}
fields={Object.values(Theme).map((theme) => ({
name: `branding.${themeToLogoName[theme]}`,
error: errors.branding?.[themeToLogoName[theme]],
type: 'organization_logo',
theme,
}))}
/>
<FormField
title="organization_details.custom_data"
tip={t('organization_details.custom_data_tip')}
@ -137,7 +153,6 @@ function Settings() {
)}
</FormField>
</FormCard>
<Branding form={form} />
<JitSettings form={form} />
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
</DetailsForm>

View file

@ -2,6 +2,7 @@ import { type Organization } from '@logto/schemas';
import { trySafe } from '@silverhand/essentials';
import { type Option } from '@/ds-components/Select/MultiSelect';
import { removeFalsyValues } from '@/utils/object';
export type FormData = Partial<Omit<Organization, 'customData'> & { customData: string }> & {
jitEmailDomains: string[];
@ -25,14 +26,6 @@ export const normalizeData = (
customData: JSON.stringify(data.customData, undefined, 2),
});
const assembleBranding = (branding?: Organization['branding']) => {
if (!branding) {
return {};
}
return Object.fromEntries(Object.entries(branding).filter(([, value]) => Boolean(value)));
};
export const assembleData = ({
jitEmailDomains,
jitRoles,
@ -42,7 +35,7 @@ export const assembleData = ({
...data
}: FormData): Partial<Organization> => ({
...data,
branding: assembleBranding(branding),
branding: branding && removeFalsyValues(branding),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
customData: JSON.parse(customData ?? '{}'),
});

View file

@ -1,29 +0,0 @@
@use '@/scss/underscore' as _;
.container {
display: flex;
flex-direction: column;
}
.uploader {
display: flex;
gap: _.unit(2);
.logoUploader {
flex: 2 0;
}
.faviconUploader {
flex: 1;
}
}
.description {
font: var(--font-body-2);
color: var(--color-text-secondary);
margin-top: _.unit(1);
}
.error {
color: var(--color-error);
}

View file

@ -1,75 +0,0 @@
import classNames from 'classnames';
import { useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import ImageUploader from '@/ds-components/Uploader/ImageUploader';
import useImageMimeTypes from '@/hooks/use-image-mime-types';
import type { SignInExperienceForm } from '@/pages/SignInExperience/types';
import * as styles from './index.module.scss';
function LogoAndFaviconUploader() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [uploadLogoError, setUploadLogoError] = useState<string>();
const [uploadFaviconError, setUploadFaviconError] = useState<string>();
const { control } = useFormContext<SignInExperienceForm>();
const { description } = useImageMimeTypes();
return (
<div className={styles.container}>
<div className={styles.uploader}>
<div className={styles.logoUploader}>
<Controller
control={control}
name="branding.logoUrl"
render={({ field: { onChange, value, name } }) => (
<ImageUploader
name={name}
value={value ?? ''}
actionDescription={t('sign_in_exp.branding.logo_image')}
onCompleted={onChange}
onUploadErrorChange={setUploadLogoError}
onDelete={() => {
onChange('');
}}
/>
)}
/>
</div>
<div className={styles.faviconUploader}>
<Controller
control={control}
name="branding.favicon"
render={({ field: { onChange, value, name } }) => (
<ImageUploader
name={name}
value={value ?? ''}
actionDescription={t('sign_in_exp.branding.favicon')}
onCompleted={onChange}
onUploadErrorChange={setUploadFaviconError}
onDelete={() => {
onChange('');
}}
/>
)}
/>
</div>
</div>
{uploadLogoError && (
<div className={classNames(styles.description, styles.error)}>
{t('sign_in_exp.branding.logo_image_error', { error: uploadLogoError })}
</div>
)}
{uploadFaviconError && (
<div className={classNames(styles.description, styles.error)}>
{t('sign_in_exp.branding.favicon_error', { error: uploadFaviconError })}
</div>
)}
<div className={styles.description}>{description}</div>
</div>
);
}
export default LogoAndFaviconUploader;

View file

@ -1,28 +1,23 @@
import { generateDarkColor } from '@logto/core-kit';
import { Theme } from '@logto/schemas';
import { useMemo, useCallback, useEffect } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import LogoAndFavicon from '@/components/ImageInputs/LogoAndFavicon';
import Button from '@/ds-components/Button';
import Card from '@/ds-components/Card';
import ColorPicker from '@/ds-components/ColorPicker';
import DangerousRaw from '@/ds-components/DangerousRaw';
import FormField from '@/ds-components/FormField';
import Switch from '@/ds-components/Switch';
import TextInput from '@/ds-components/TextInput';
import ImageUploaderField from '@/ds-components/Uploader/ImageUploaderField';
import useUserAssetsService from '@/hooks/use-user-assets-service';
import { uriValidator } from '@/utils/validator';
import type { SignInExperienceForm } from '../../../types';
import FormSectionTitle from '../../components/FormSectionTitle';
import LogoAndFaviconUploader from './LogoAndFaviconUploader';
import * as styles from './index.module.scss';
function BrandingForm() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { isReady: isUserAssetsServiceReady } = useUserAssetsService();
const {
watch,
register,
@ -66,43 +61,17 @@ function BrandingForm() {
)}
/>
</FormField>
{isUserAssetsServiceReady ? (
<FormField
title={
<DangerousRaw>
{t('sign_in_exp.branding.logo_image')}
{' & '}
{t('sign_in_exp.branding.favicon')}
</DangerousRaw>
}
headlineSpacing="large"
>
<LogoAndFaviconUploader />
</FormField>
) : (
<>
<FormField title="sign_in_exp.branding.logo_image_url">
<TextInput
{...register('branding.logoUrl', {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
error={errors.branding?.logoUrl?.message}
placeholder={t('sign_in_exp.branding.logo_image_url_placeholder')}
/>
</FormField>
<FormField title="sign_in_exp.branding.favicon">
<TextInput
{...register('branding.favicon', {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
error={errors.branding?.favicon?.message}
placeholder={t('sign_in_exp.branding.favicon')}
/>
</FormField>
</>
)}
<LogoAndFavicon
control={control}
register={register}
theme={Theme.Light}
type="company_logo"
logo={{ name: 'branding.logoUrl', error: errors.branding?.logoUrl }}
favicon={{
name: 'branding.favicon',
error: errors.branding?.favicon,
}}
/>
<FormField title="sign_in_exp.color.dark_mode">
<Switch
label={t('sign_in_exp.color.dark_mode_description')}
@ -131,33 +100,17 @@ function BrandingForm() {
</div>
)}
</FormField>
{isUserAssetsServiceReady ? (
<FormField title="sign_in_exp.branding.dark_logo_image" headlineSpacing="large">
<Controller
name="branding.darkLogoUrl"
control={control}
render={({ field: { onChange, value, name } }) => (
<ImageUploaderField
name={name}
value={value ?? ''}
actionDescription={t('sign_in_exp.branding.dark_logo_image')}
onChange={onChange}
/>
)}
/>
</FormField>
) : (
<FormField title="sign_in_exp.branding.dark_logo_image_url">
<TextInput
{...register('branding.darkLogoUrl', {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
error={errors.branding?.darkLogoUrl?.message}
placeholder={t('sign_in_exp.branding.dark_logo_image_url_placeholder')}
/>
</FormField>
)}
<LogoAndFavicon
control={control}
register={register}
theme={Theme.Dark}
type="company_logo"
logo={{ name: 'branding.darkLogoUrl', error: errors.branding?.darkLogoUrl }}
favicon={{
name: 'branding.darkFavicon',
error: errors.branding?.darkFavicon,
}}
/>
</>
)}
</Card>

View file

@ -5,7 +5,9 @@ import {
type SignUp,
type SignInIdentifier,
} from '@logto/schemas';
import { conditional, isSameArray } from '@silverhand/essentials';
import { isSameArray } from '@silverhand/essentials';
import { removeFalsyValues } from '@/utils/object';
import {
type UpdateSignInExperienceData,
@ -79,13 +81,7 @@ export const sieFormDataParser = {
return {
...formData,
branding: {
...branding,
// Transform empty string to undefined
favicon: conditional(branding.favicon?.length && branding.favicon),
logoUrl: conditional(branding.logoUrl?.length && branding.logoUrl),
darkLogoUrl: conditional(branding.darkLogoUrl?.length && branding.darkLogoUrl),
},
branding: removeFalsyValues(branding),
signUp: signUpFormDataParser.toSignUp(signUp),
signInMode: createAccountEnabled ? SignInMode.SignInAndRegister : SignInMode.SignIn,
customCss: customCss?.length ? customCss : null,

View file

@ -0,0 +1,2 @@
export const removeFalsyValues = (object: Record<string, unknown>) =>
Object.fromEntries(Object.entries(object).filter(([, value]) => value));

View file

@ -5,7 +5,11 @@ import { logtoConsoleUrl as logtoConsoleUrlString, logtoUrl } from '#src/constan
import { goToAdminConsole } from '#src/ui-helpers/index.js';
import { expectNavigation, appendPathname } from '#src/utils.js';
import { expectToSelectPreviewLanguage, waitForFormCard } from './helpers.js';
import {
expectToSaveSignInExperience,
expectToSelectPreviewLanguage,
waitForFormCard,
} from './helpers.js';
await page.setViewport({ width: 1920, height: 1080 });
@ -82,6 +86,9 @@ describe('sign-in experience: sign-in preview', () => {
await expect(page).toClick(
'form div[class$=field] label[class$=switch]:has(input[name="color.isDarkModeEnabled"])'
);
// Save since the switch will lead a dirty form
await expectToSaveSignInExperience(page);
});
it('switch between preview languages', async () => {

View file

@ -34,15 +34,6 @@ const sign_in_exp = {
branding: {
title: 'BRANDING',
ui_style: 'Stil',
favicon: 'Favicon',
logo_image_url: 'App Logo-URL',
logo_image_url_placeholder: 'https://dein.cdn.domain/logo.png',
dark_logo_image_url: 'App Logo-URL (Dunkler Modus)',
dark_logo_image_url_placeholder: 'https://dein.cdn.domain/logo-dark.png',
logo_image: 'App-Logo',
dark_logo_image: 'App-Logo (Dunkler Modus)',
logo_image_error: 'App-Logo: {{error}}',
favicon_error: 'Favicon: {{error}}',
},
custom_css: {
title: 'Benutzerdefiniertes CSS',

View file

@ -104,7 +104,6 @@ const application_details = {
display_name: 'Display name',
application_logo: 'Application logo',
application_logo_dark: 'Application logo (dark)',
use_different_brand_color: 'Use a different brand color for the app-level sign-in experience',
brand_color: 'Brand color',
brand_color_dark: 'Brand color (dark)',
terms_of_use_url: 'Application terms of use URL',

View file

@ -39,12 +39,7 @@ const organization_details = {
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
invalid_json_object: 'Invalid JSON object.',
branding: {
title: 'Branding',
description:
'Customize the branding of the organization. The branding can be used in the sign-in experience or for your own reference.',
light_logo: 'Organization logo',
dark_logo: 'Organization logo (dark)',
logo_upload_description: 'Click or drop an image to upload',
logo: 'Organization logos',
},
jit: {
title: 'Just-in-time provisioning',

View file

@ -33,15 +33,35 @@ const sign_in_exp = {
branding: {
title: 'BRANDING AREA',
ui_style: 'Style',
favicon: 'Favicon',
logo_image_url: 'App logo image URL',
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: 'App logo image URL (dark)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'App logo',
dark_logo_image: 'App logo (dark)',
logo_image_error: 'App logo: {{error}}',
favicon_error: 'Favicon: {{error}}',
with_light: '{{value}}',
with_dark: '{{value}} (dark)',
app_logo_and_favicon: 'App logo and favicon',
},
branding_uploads: {
app_logo: {
title: 'App logo',
url: 'App logo URL',
url_placeholder: 'https://your.cdn.domain/logo.png',
error: 'App logo: {{error}}',
},
company_logo: {
title: 'Company logo',
url: 'Company logo URL',
url_placeholder: 'https://your.cdn.domain/logo.png',
error: 'Company logo: {{error}}',
},
organization_logo: {
title: 'Upload image',
url: 'Organization logo URL',
url_placeholder: 'https://your.cdn.domain/logo.png',
error: 'Organization logo: {{error}}',
},
favicon: {
title: 'Favicon',
url: 'Favicon URL',
url_placeholder: 'https://your.cdn.domain/favicon.ico',
error: 'Favicon: {{error}}',
},
},
custom_css: {
title: 'Custom CSS',

View file

@ -35,15 +35,6 @@ const sign_in_exp = {
branding: {
title: 'ÁREA DE BRANDING',
ui_style: 'Estilo',
favicon: 'Favicon',
logo_image_url: 'URL de imagen del logotipo de la aplicación',
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: 'URL de imagen del logotipo de la aplicación (oscuro)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'Logotipo de la aplicación',
dark_logo_image: 'Logotipo de la aplicación (oscuro)',
logo_image_error: 'Logotipo de la aplicación: {{error}}',
favicon_error: 'Favicon: {{error}}',
},
custom_css: {
title: 'CSS personalizado',

View file

@ -35,15 +35,6 @@ const sign_in_exp = {
branding: {
title: 'ZONE DE MARQUE',
ui_style: 'Style',
favicon: 'Favicon',
logo_image_url: "URL de l'image du logo de l'application",
logo_image_url_placeholder: 'https://votre.domaine.cdn/logo.png',
dark_logo_image_url: "URL de l'image du logo de l'application (Sombre)",
dark_logo_image_url_placeholder: 'https://votre.domaine.cdn/logo-dark.png',
logo_image: "Logo de l'application",
dark_logo_image: "Logo de l'application (sombre)",
logo_image_error: "Logo de l'application : {{error}}",
favicon_error: 'Favicon : {{error}}',
},
custom_css: {
title: 'CSS personnalisé',

View file

@ -34,15 +34,6 @@ const sign_in_exp = {
branding: {
title: 'AREA DI BRANDIZZO',
ui_style: 'Stile',
favicon: 'Favicon',
logo_image_url: "URL dell'immagine del logo dell'app",
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: "URL dell'immagine del logo dell'app (scuro)",
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: "Logo dell'app",
dark_logo_image: "Logo dell'app (scuro)",
logo_image_error: "Logo dell'app: {{error}}",
favicon_error: 'Favicon: {{error}}',
},
custom_css: {
title: 'CSS personalizzato',

View file

@ -33,15 +33,6 @@ const sign_in_exp = {
branding: {
title: 'ブランディングエリア',
ui_style: 'スタイル',
favicon: 'ファビコン',
logo_image_url: 'アプリのロゴ画像URL',
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: 'アプリのロゴ画像URLダーク',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'アプリのロゴ',
dark_logo_image: 'アプリのロゴ(ダーク)',
logo_image_error: 'アプリのロゴ:{{error}}',
favicon_error: 'ファビコン:{{error}}',
},
custom_css: {
title: 'カスタムCSS',

View file

@ -31,15 +31,6 @@ const sign_in_exp = {
branding: {
title: '브랜딩 영역',
ui_style: '스타일',
favicon: '파비콘',
logo_image_url: '앱 로고 이미지 URL',
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: '앱 로고 이미지 URL (다크 모드)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: '앱 로고',
dark_logo_image: '앱 로고 (다크 모드)',
logo_image_error: '앱 로고: {{error}}',
favicon_error: '파비콘: {{error}}',
},
custom_css: {
title: '사용자 정의 CSS',

View file

@ -34,15 +34,6 @@ const sign_in_exp = {
branding: {
title: 'OPCJE BRANDINGU',
ui_style: 'Styl',
favicon: 'Favicon',
logo_image_url: 'Adres URL obrazka logo aplikacji',
logo_image_url_placeholder: 'https://twoja.domena.cdn/logo.png',
dark_logo_image_url: 'Adres URL obrazka logo aplikacji (Ciemny)',
dark_logo_image_url_placeholder: 'https://twoja.domena.cdn/logo-ciemny.png',
logo_image: 'Logo aplikacji',
dark_logo_image: 'Logo aplikacji (Ciemny)',
logo_image_error: 'Logo aplikacji: {{error}}',
favicon_error: 'Favicon: {{error}}',
},
custom_css: {
title: 'Niestandardowe CSS',

View file

@ -34,15 +34,6 @@ const sign_in_exp = {
branding: {
title: 'ÁREA DE MARCA',
ui_style: 'Estilo',
favicon: 'Favicon',
logo_image_url: 'URL da imagem do logotipo do aplicativo',
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: 'URL da imagem do logotipo do aplicativo (Escuro)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'Logotipo do aplicativo',
dark_logo_image: 'Logotipo do aplicativo (Escuro)',
logo_image_error: 'Logotipo do aplicativo: {{error}}',
favicon_error: 'Favicon: {{error}}',
},
custom_css: {
title: 'CSS personalizado',

View file

@ -33,15 +33,6 @@ const sign_in_exp = {
branding: {
title: 'ÁREA DE MARCA',
ui_style: 'Estilo',
favicon: 'Favicon',
logo_image_url: 'URL do logotipo da app',
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: 'URL do logotipo da app (tema escuro)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'Logótipo da aplicação',
dark_logo_image: 'Logótipo da aplicação (escuro)',
logo_image_error: 'Logótipo da aplicação: {{error}}',
favicon_error: 'Favicon: {{error}}',
},
custom_css: {
title: 'CSS Personalizado',

View file

@ -34,15 +34,6 @@ const sign_in_exp = {
branding: {
title: 'ЗОНА БРЕНДИНГА',
ui_style: 'Стиль',
favicon: 'Фавикон',
logo_image_url: 'URL изображения логотипа приложения',
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: 'URL изображения логотипа приложения (темный)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'Логотип приложения',
dark_logo_image: 'Логотип приложения (темный)',
logo_image_error: 'Ошибка логотипа приложения: {{error}}',
favicon_error: 'Ошибка фавикона: {{error}}',
},
custom_css: {
title: 'Пользовательский CSS',

View file

@ -34,15 +34,6 @@ const sign_in_exp = {
branding: {
title: 'MARKA ALANI',
ui_style: 'Stil',
favicon: 'Favicon',
logo_image_url: 'Uygulama logosu resim URLi',
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: 'Uygulama logosu resim URLi (Koyu)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'Uygulama logosu',
dark_logo_image: 'Uygulama logosu (Koyu)',
logo_image_error: 'Uygulama logosu: {{error}}',
favicon_error: 'Favicon: {{error}}',
},
custom_css: {
title: 'Özel CSS',

View file

@ -31,15 +31,6 @@ const sign_in_exp = {
branding: {
title: '品牌定制区',
ui_style: '样式',
favicon: '浏览器地址栏图标',
logo_image_url: 'Logo 图片 URL',
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: 'Logo 图片 URL (深色)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'Logo 图片',
dark_logo_image: 'Logo 图片(深色)',
logo_image_error: '应用 Logo{{error}}',
favicon_error: 'Favicon{{error}}',
},
custom_css: {
title: '自定义 CSS',

View file

@ -31,15 +31,6 @@ const sign_in_exp = {
branding: {
title: '品牌定制區',
ui_style: '樣式',
favicon: '瀏覽器地址欄圖標',
logo_image_url: 'Logo 圖片 URL',
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: 'Logo 圖片 URL (深色)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'Logo 圖片',
dark_logo_image: 'Logo 圖片(深色)',
logo_image_error: '應用 Logo{{error}}',
favicon_error: 'Favicon{{error}}',
},
custom_css: {
title: '自定義 CSS',

View file

@ -31,15 +31,6 @@ const sign_in_exp = {
branding: {
title: '品牌定制區',
ui_style: '樣式',
favicon: '瀏覽器地址欄圖標',
logo_image_url: 'Logo 圖片 URL',
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: 'Logo 圖片 URL (深色)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'Logo 圖片',
dark_logo_image: 'Logo 圖片(深色)',
logo_image_error: '應用 Logo{{error}}',
favicon_error: 'Favicon{{error}}',
},
custom_css: {
title: '自定義 CSS',

View file

@ -23,11 +23,14 @@ export const themeToLogoKey = Object.freeze({
[Theme.Dark]: 'darkLogoUrl',
} satisfies Record<Theme, keyof Branding>);
export const brandingGuard = z.object({
logoUrl: z.string().url().optional(),
darkLogoUrl: z.string().url().optional(),
favicon: z.string().url().optional(),
});
export const brandingGuard = z
.object({
logoUrl: z.string().url(),
darkLogoUrl: z.string().url(),
favicon: z.string().url(),
darkFavicon: z.string().url(),
})
.partial();
export type Branding = z.infer<typeof brandingGuard>;