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:
commit
6060919a21
39 changed files with 409 additions and 630 deletions
|
@ -15,13 +15,3 @@
|
|||
.text + .text {
|
||||
margin-top: _.unit(2);
|
||||
}
|
||||
|
||||
.field {
|
||||
@include _.shimmering-animation;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.field + .field {
|
||||
margin-top: _.unit(6);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
155
packages/console/src/components/ImageInputs/index.tsx
Normal file
155
packages/console/src/components/ImageInputs/index.tsx
Normal 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;
|
|
@ -0,0 +1,11 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.field {
|
||||
@include _.shimmering-animation;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.field + .field {
|
||||
margin-top: _.unit(6);
|
||||
}
|
18
packages/console/src/ds-components/FormField/Skeleton.tsx
Normal file
18
packages/console/src/ds-components/FormField/Skeleton.tsx
Normal 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;
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -22,12 +22,6 @@
|
|||
gap: _.unit(3);
|
||||
}
|
||||
|
||||
.branding {
|
||||
section + section {
|
||||
margin-top: _.unit(6);
|
||||
}
|
||||
}
|
||||
|
||||
.mfaWarning {
|
||||
margin-top: _.unit(3);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 ?? '{}'),
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
2
packages/console/src/utils/object.ts
Normal file
2
packages/console/src/utils/object.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export const removeFalsyValues = (object: Record<string, unknown>) =>
|
||||
Object.fromEntries(Object.entries(object).filter(([, value]) => value));
|
|
@ -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 () => {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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é',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
Loading…
Reference in a new issue