0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

refactor: remove branding style config (#3326)

This commit is contained in:
Xiao Yijun 2023-03-09 11:23:06 +08:00 committed by GitHub
parent 95b6fb2613
commit 105390f004
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 198 additions and 274 deletions

View file

@ -0,0 +1,9 @@
---
"@logto/console": minor
"@logto/core": minor
"@logto/phrases": minor
"@logto/schemas": minor
"@logto/ui": minor
---
remove the branding style config and make the logo URL config optional

View file

@ -1,10 +1,8 @@
import { BrandingStyle } from '@logto/schemas';
import { Controller, useFormContext } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Card from '@/components/Card';
import FormField from '@/components/FormField';
import RadioGroup, { Radio } from '@/components/RadioGroup';
import TextInput from '@/components/TextInput';
import { uriValidator } from '@/utils/validator';
@ -16,17 +14,15 @@ const BrandingForm = () => {
const {
watch,
register,
control,
formState: { errors },
} = useFormContext<SignInExperienceForm>();
const isDarkModeEnabled = watch('color.isDarkModeEnabled');
const style = watch('branding.style');
const isSloganRequired = style === BrandingStyle.Logo_Slogan;
return (
<Card>
<div className={styles.title}>{t('sign_in_exp.branding.title')}</div>
<FormField title="sign_in_exp.branding.favicon">
<TextInput
{...register('branding.favicon', {
@ -37,26 +33,9 @@ const BrandingForm = () => {
placeholder={t('sign_in_exp.branding.favicon')}
/>
</FormField>
<FormField title="sign_in_exp.branding.ui_style">
<Controller
name="branding.style"
control={control}
defaultValue={BrandingStyle.Logo_Slogan}
render={({ field: { onChange, value, name } }) => (
<RadioGroup value={value} name={name} className={styles.radioGroup} onChange={onChange}>
<Radio
value={BrandingStyle.Logo_Slogan}
title="sign_in_exp.branding.styles.logo_slogan"
/>
<Radio value={BrandingStyle.Logo} title="sign_in_exp.branding.styles.logo" />
</RadioGroup>
)}
/>
</FormField>
<FormField isRequired title="sign_in_exp.branding.logo_image_url">
<FormField title="sign_in_exp.branding.logo_image_url">
<TextInput
{...register('branding.logoUrl', {
required: true,
validate: (value) => !value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
hasError={Boolean(errors.branding?.logoUrl)}
@ -76,15 +55,6 @@ const BrandingForm = () => {
/>
</FormField>
)}
{isSloganRequired && (
<FormField isRequired={isSloganRequired} title="sign_in_exp.branding.slogan">
<TextInput
{...register('branding.slogan', { required: isSloganRequired })}
hasError={Boolean(errors.branding?.slogan)}
placeholder={t('sign_in_exp.branding.slogan_placeholder')}
/>
</FormField>
)}
</Card>
);
};

View file

@ -48,8 +48,8 @@ export const signInExperienceParser = {
...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),
slogan: conditional(branding.slogan?.length && branding.slogan),
},
signUp: signUp
? signInExperienceParser.toRemoteSignUp(signUp)

View file

@ -6,7 +6,7 @@ import type {
SignUp,
SignIn,
} from '@logto/schemas';
import { BrandingStyle, SignInMode, SignInIdentifier } from '@logto/schemas';
import { SignInMode, SignInIdentifier } from '@logto/schemas';
export const mockColor: Color = {
primaryColor: '#000',
@ -15,9 +15,7 @@ export const mockColor: Color = {
};
export const mockBranding: Branding = {
style: BrandingStyle.Logo_Slogan,
logoUrl: 'http://silverhand.png',
slogan: 'Silverhand.',
};
export const mockTermsOfUseUrl = 'http://silverhand.com/terms';
@ -54,9 +52,7 @@ export const mockSignInExperience: SignInExperience = {
darkPrimaryColor: '#fff',
},
branding: {
style: BrandingStyle.Logo,
logoUrl: 'http://logto.png',
slogan: 'logto',
},
termsOfUseUrl: mockTermsOfUseUrl,
privacyPolicyUrl: mockPrivacyPolicyUrl,

View file

@ -1,12 +1,10 @@
import type { LanguageTag } from '@logto/language-kit';
import { builtInLanguages } from '@logto/phrases-ui';
import type { CreateSignInExperience, SignInExperience } from '@logto/schemas';
import { BrandingStyle } from '@logto/schemas';
import {
socialTarget01,
socialTarget02,
mockBranding,
mockSignInExperience,
mockSocialConnectors,
} from '#src/__mocks__/index.js';
@ -42,7 +40,7 @@ const queries = new MockQueries({
const connectorLibrary = createConnectorLibrary(queries);
const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors');
const { validateBranding, createSignInExperienceLibrary } = await import('./index.js');
const { createSignInExperienceLibrary } = await import('./index.js');
const { validateLanguageInfo, removeUnavailableSocialConnectorTargets } =
createSignInExperienceLibrary(queries, connectorLibrary);
@ -50,48 +48,6 @@ beforeEach(() => {
jest.clearAllMocks();
});
describe('validate branding', () => {
it('should throw when the UI style contains the slogan and slogan is empty', () => {
expect(() => {
validateBranding({
...mockBranding,
style: BrandingStyle.Logo_Slogan,
slogan: '',
});
}).toMatchError(new RequestError('sign_in_experiences.empty_slogan'));
});
it('should throw when the logo is empty', () => {
expect(() => {
validateBranding({
...mockBranding,
style: BrandingStyle.Logo,
logoUrl: ' ',
slogan: '',
});
}).toMatchError(new RequestError('sign_in_experiences.empty_logo'));
});
it('should throw when the UI style contains the slogan and slogan is blank', () => {
expect(() => {
validateBranding({
...mockBranding,
style: BrandingStyle.Logo_Slogan,
slogan: ' \t\n',
});
}).toMatchError(new RequestError('sign_in_experiences.empty_slogan'));
});
it('should not throw when the UI style does not contain the slogan and slogan is empty', () => {
expect(() => {
validateBranding({
...mockBranding,
style: BrandingStyle.Logo,
});
}).not.toThrow();
});
});
describe('validate language info', () => {
it('should call findAllCustomLanguageTags', async () => {
await validateLanguageInfo({

View file

@ -1,8 +1,7 @@
import { builtInLanguages } from '@logto/phrases-ui';
import type { Branding, LanguageInfo, SignInExperience } from '@logto/schemas';
import { ConnectorType, BrandingStyle } from '@logto/schemas';
import type { LanguageInfo, SignInExperience } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { deduplicate } from '@silverhand/essentials';
import i18next from 'i18next';
import RequestError from '#src/errors/RequestError/index.js';
import type { ConnectorLibrary } from '#src/libraries/connector.js';
@ -12,14 +11,6 @@ import assertThat from '#src/utils/assert-that.js';
export * from './sign-up.js';
export * from './sign-in.js';
export const validateBranding = (branding: Branding) => {
if (branding.style === BrandingStyle.Logo_Slogan) {
assertThat(branding.slogan?.trim(), 'sign_in_experiences.empty_slogan');
}
assertThat(branding.logoUrl.trim(), 'sign_in_experiences.empty_logo');
};
export type SignInExperienceLibrary = ReturnType<typeof createSignInExperienceLibrary>;
export const createSignInExperienceLibrary = (
@ -61,16 +52,7 @@ export const createSignInExperienceLibrary = (
});
};
const getSignInExperience = async (): Promise<SignInExperience> => {
const raw = await findDefaultSignInExperience();
const { branding } = raw;
// Alter sign-in experience dynamic configs
return Object.freeze({
...raw,
branding: { ...branding, slogan: branding.slogan && i18next.t(branding.slogan) },
});
};
const getSignInExperience = async (): Promise<SignInExperience> => findDefaultSignInExperience();
return {
validateLanguageInfo,

View file

@ -1,5 +1,4 @@
import type { CreateSignInExperience, SignInExperience } from '@logto/schemas';
import { BrandingStyle } from '@logto/schemas';
import { pickDefault } from '@logto/shared/esm';
import { mockBranding, mockSignInExperience } from '#src/__mocks__/index.js';
@ -31,18 +30,6 @@ const expectPatchResponseStatus = async (
};
describe('branding', () => {
describe('style', () => {
test.each(Object.values(BrandingStyle))('%p should succeed', async (style) => {
const signInExperience = { branding: { ...mockBranding, style } };
await expectPatchResponseStatus(signInExperience, 200);
});
test.each([undefined, '', 'invalid'])('%p should fail', async (style) => {
const signInExperience = { branding: { ...mockBranding, style } };
await expectPatchResponseStatus(signInExperience, 400);
});
});
describe('logoUrl', () => {
test.each(['http://silverhand.com/silverhand.png', 'https://logto.dev/logto.jpg'])(
'%p should success',
@ -52,36 +39,14 @@ describe('branding', () => {
}
);
test.each([undefined, null, '', 'invalid'])('%p should fail', async (logoUrl) => {
test.each([null, '', 'invalid'])('%p should fail', async (logoUrl) => {
const signInExperience = { branding: { ...mockBranding, logoUrl } };
await expectPatchResponseStatus(signInExperience, 400);
});
});
describe('slogan', () => {
test.each([undefined, 'Silverhand.', 'Supercharge innovations.'])(
'%p should success',
async (slogan) => {
const signInExperience = {
branding: {
...mockBranding,
style: BrandingStyle.Logo,
slogan,
},
};
await expectPatchResponseStatus(signInExperience, 200);
}
);
test.each([null])('%p should fail', async (slogan) => {
const signInExperience = {
branding: {
...mockBranding,
style: BrandingStyle.Logo,
slogan,
},
};
await expectPatchResponseStatus(signInExperience, 400);
it('should success when logoUrl is not provided', async () => {
const signInExperience = { branding: { ...mockBranding, logoUrl: undefined } };
await expectPatchResponseStatus(signInExperience, 200);
});
});

View file

@ -30,10 +30,9 @@ const logtoConnectors = [
mockAliyunSmsConnector,
];
const { validateBranding, validateSignIn, validateSignUp } = await mockEsmWithActual(
const { validateSignIn, validateSignUp } = await mockEsmWithActual(
'#src/libraries/sign-in-experience/index.js',
() => ({
validateBranding: jest.fn(),
validateSignIn: jest.fn(),
validateSignUp: jest.fn(),
})
@ -125,7 +124,6 @@ describe('PATCH /sign-in-exp', () => {
signIn: mockSignIn,
});
expect(validateBranding).toHaveBeenCalledWith(mockBranding);
expect(validateLanguageInfo).toHaveBeenCalledWith(mockLanguageInfo);
expect(validateSignUp).toHaveBeenCalledWith(mockSignUp, logtoConnectors);
expect(validateSignIn).toHaveBeenCalledWith(mockSignIn, mockSignUp, logtoConnectors);

View file

@ -1,11 +1,7 @@
import { ConnectorType, SignInExperiences } from '@logto/schemas';
import { literal, object, string } from 'zod';
import {
validateBranding,
validateSignUp,
validateSignIn,
} from '#src/libraries/sign-in-experience/index.js';
import { validateSignUp, validateSignIn } from '#src/libraries/sign-in-experience/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type { AuthedRouter, RouterInitArgs } from '../types.js';
@ -44,11 +40,7 @@ export default function signInExperiencesRoutes<T extends AuthedRouter>(
}),
async (ctx, next) => {
const { socialSignInConnectorTargets, ...rest } = ctx.guard.body;
const { branding, languageInfo, signUp, signIn } = rest;
if (branding) {
validateBranding(branding);
}
const { languageInfo, signUp, signIn } = rest;
if (languageInfo) {
await validateLanguageInfo(languageInfo);

View file

@ -1,5 +1,3 @@
import { BrandingStyle } from '@logto/schemas';
import { getSignInExperience, updateSignInExperience } from '#src/api/index.js';
describe('admin console sign-in experience', () => {
@ -17,8 +15,6 @@ describe('admin console sign-in experience', () => {
isDarkModeEnabled: true,
},
branding: {
style: BrandingStyle.Logo_Slogan,
slogan: 'Logto Slogan',
logoUrl: 'https://logto.io/new-logo.png',
darkLogoUrl: 'https://logto.io/new-dark-logo.png',
},

View file

@ -144,9 +144,6 @@ const errors = {
sign_in_experiences: {
empty_content_url_of_terms_of_use:
'Leere "Nutzungsbedingungen" URL. Bitte füge die URL hinzu, wenn "Nutzungsbedingungen" aktiviert ist.',
empty_logo: 'Bitte füge eine Logo URL hinzu.',
empty_slogan:
'Leerer Branding-Slogan. Bitte füge einen Branding-Slogan hinzu, wenn ein UI-Stil ausgewählt wird, der den Slogan enthält.',
empty_social_connectors:
'Leere Social Connectors. Bitte füge aktivierte Social Connectoren hinzu, wenn Social Anmeldung aktiviert ist.',
enabled_connector_not_found: 'Aktivierter {{type}} Connector nicht gefunden.',

View file

@ -85,16 +85,10 @@ const sign_in_exp = {
title: 'BRANDING',
ui_style: 'Stil',
favicon: 'Browser favicon', // UNTRANSLATED
styles: {
logo_slogan: 'App logo mit Slogan',
logo: 'Nur App logo',
},
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',
slogan: 'Slogan',
slogan_placeholder: 'Entfessle deine Kreativität',
},
others: {
terms_of_use: {

View file

@ -143,9 +143,6 @@ const errors = {
sign_in_experiences: {
empty_content_url_of_terms_of_use:
'Empty "Terms of use" content URL. Please add the content URL if "Terms of use" is enabled.',
empty_logo: 'Please enter your logo URL',
empty_slogan:
'Empty branding slogan. Please add a branding slogan if a UI style containing the slogan is selected.',
empty_social_connectors:
'Empty social connectors. Please add enabled social connectors when the social sign-in method is enabled.',
enabled_connector_not_found: 'Enabled {{type}} connector not found.',

View file

@ -28,16 +28,10 @@ const sign_in_exp = {
title: 'BRANDING AREA',
ui_style: 'Style',
favicon: 'Browser favicon',
styles: {
logo_slogan: 'App logo with slogan',
logo: 'App logo only',
},
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',
slogan: 'Slogan',
slogan_placeholder: 'Unleash your creativity',
},
sign_up_and_sign_in: {
identifiers_email: 'Email address',

View file

@ -150,9 +150,6 @@ const errors = {
sign_in_experiences: {
empty_content_url_of_terms_of_use:
'URL de contenu "Conditions d\'utilisation" vide. Veuillez ajouter l\'URL du contenu si les "Conditions d\'utilisation" sont activées.',
empty_logo: "Veuillez entrer l'URL de votre logo",
empty_slogan:
"Un slogan vide. Veuillez ajouter un slogan si un style d'interface utilisateur contenant le slogan est sélectionné.",
empty_social_connectors:
'Connecteurs sociaux vides. Veuillez ajouter des connecteurs sociaux activés lorsque la méthode de connexion sociale est activée.',
enabled_connector_not_found: 'Le connecteur {{type}} activé est introuvable.',

View file

@ -30,16 +30,10 @@ const sign_in_exp = {
title: 'ZONE DE MARQUE',
ui_style: 'Style',
favicon: 'Browser favicon', // UNTRANSLATED
styles: {
logo_slogan: "Logo de l'application avec slogan",
logo: "Logo de l'application seulement",
},
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',
slogan: 'Slogan',
slogan_placeholder: 'Libérez votre créativité',
},
sign_up_and_sign_in: {
identifiers_email: 'Email address', // UNTRANSLATED

View file

@ -137,8 +137,6 @@ const errors = {
sign_in_experiences: {
empty_content_url_of_terms_of_use:
'이용 약관 URL이 비어 있어요. 이용 약관이 활성화되어 있다면, 이용 약관 URL를 설정해 주세요.',
empty_logo: '로고 URL을 입력해 주세요.',
empty_slogan: '브랜딩 슬로건이 비어 있어요. 슬로건을 사용한다면, 내용을 설정해 주세요.',
empty_social_connectors: '연동된 소셜이 없어요. 소셜 로그인을 사용한다면, 연동해 주세요.',
enabled_connector_not_found: '활성된 {{type}} 연동을 찾을 수 없어요.',
not_one_and_only_one_primary_sign_in_method:

View file

@ -26,16 +26,10 @@ const sign_in_exp = {
title: '브랜딩 영역',
ui_style: '스타일',
favicon: 'Browser favicon', // UNTRANSLATED
styles: {
logo_slogan: '앱 로고 & 슬로건',
logo: '앱 로고만',
},
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',
slogan: '슬로건',
slogan_placeholder: '상상력을 펼쳐 보세요',
},
sign_up_and_sign_in: {
identifiers_email: '이메일 주소',

View file

@ -147,9 +147,6 @@ const errors = {
sign_in_experiences: {
empty_content_url_of_terms_of_use:
'URL de conteúdo "Termos de uso" vazia. Adicione o URL do conteúdo se "Termos de uso" estiver ativado.',
empty_logo: 'Insira o URL do seu logotipo',
empty_slogan:
'Slogan de marca vazio. Adicione um slogan de marca se um estilo de IU contendo o slogan for selecionado.',
empty_social_connectors:
'Conectores sociais vazios. Adicione conectores sociais ativados quando o método de login social estiver ativado.',
enabled_connector_not_found: 'Conector {{type}} ativado não encontrado.',

View file

@ -29,16 +29,10 @@ const sign_in_exp = {
title: 'ÁREA DE MARCA',
ui_style: 'Estilo',
favicon: 'Browser favicon', // UNTRANSLATED
styles: {
logo_slogan: 'Logo do aplicativo com slogan',
logo: 'Somente logotipo do aplicativo',
},
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',
slogan: 'Slogan',
slogan_placeholder: 'Use sua criatividade',
},
sign_up_and_sign_in: {
identifiers_email: 'Endereço de e-mail',

View file

@ -145,9 +145,6 @@ const errors = {
sign_in_experiences: {
empty_content_url_of_terms_of_use:
'URL dos "Termos de uso" vazio. Adicione o URL se os "Termos de uso" estiverem ativados.',
empty_logo: 'Insira o URL do seu logotipo',
empty_slogan:
'Slogan de marca vazio. Adicione um slogan se o estilo da interface com o slogan for selecionado.',
empty_social_connectors:
'Conectores sociais vazios. Adicione conectores sociais e ative os quando o método de login social estiver ativado.',
enabled_connector_not_found: 'Conector {{type}} ativado não encontrado.',

View file

@ -28,16 +28,10 @@ const sign_in_exp = {
title: 'ÁREA DE MARCA',
ui_style: 'Estilo',
favicon: 'Browser favicon', // UNTRANSLATED
styles: {
logo_slogan: 'Logo da app com slogan',
logo: 'Apenas o logo da app',
},
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',
slogan: 'Slogan',
slogan_placeholder: 'Liberte a sua criatividade',
},
sign_up_and_sign_in: {
identifiers_email: 'Email address', // UNTRANSLATED

View file

@ -144,9 +144,6 @@ const errors = {
sign_in_experiences: {
empty_content_url_of_terms_of_use:
'"Kullanım Koşulları" İçerik URLi yok. Lütfen "Kullanım Koşulları" etkinse içerik URLi ekleyiniz.',
empty_logo: 'Lütfen logo URLini giriniz',
empty_slogan:
'Marka sloganı yok. Eğer UI stili slogan içeriyorsa, lütfen bir marka sloganı ekleyin.',
empty_social_connectors:
'Social connectors yok. Sosyal oturum açma yöntemi etkinleştirildiğinde lütfen etkin social connectorları ekleyiniz.',
enabled_connector_not_found: 'Etkin {{type}} bağlayıcı bulunamadı.',

View file

@ -29,16 +29,10 @@ const sign_in_exp = {
title: 'MARKA ALANI',
ui_style: 'Stil',
favicon: 'Browser favicon', // UNTRANSLATED
styles: {
logo_slogan: 'Sloganlı şekilde uygulama logosu',
logo: 'Yalnızca uygulama logosu',
},
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',
slogan: 'Slogan',
slogan_placeholder: 'Yaratıcılığınızıığa çıkarın',
},
sign_up_and_sign_in: {
identifiers_email: 'Email address', // UNTRANSLATED

View file

@ -130,8 +130,6 @@ const errors = {
},
sign_in_experiences: {
empty_content_url_of_terms_of_use: '你启用了“使用条款”,请添加使用条款 URL。',
empty_logo: '请输入 logo URL',
empty_slogan: '你选择了 App logo + 标语的布局。请输入你的标语。',
empty_social_connectors: '你启用了社交登录的方式。请至少选择一个社交连接器。',
enabled_connector_not_found: '未找到已启用的 {{type}} 连接器',
not_one_and_only_one_primary_sign_in_method: '主要的登录方式必须有且仅有一个,请检查你的输入。',

View file

@ -27,16 +27,10 @@ const sign_in_exp = {
title: '品牌定制区',
ui_style: '样式',
favicon: '浏览器地址栏图标',
styles: {
logo_slogan: 'Logo 和标语',
logo: '仅有 Logo',
},
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',
slogan: '标语',
slogan_placeholder: '释放你的创意',
},
sign_up_and_sign_in: {
identifiers_email: '邮件地址',

View file

@ -0,0 +1,112 @@
import type { DatabaseTransactionConnection } from 'slonik';
import { sql } from 'slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
enum DeprecatedBrandingStyle {
Logo = 'Logo',
Logo_Slogan = 'Logo_Slogan',
}
const deprecatedDefaultBrandingStyle = DeprecatedBrandingStyle.Logo_Slogan;
const deprecatedDefaultSlogan = 'admin_console.welcome.title';
type DeprecatedBranding = {
style?: DeprecatedBrandingStyle;
logoUrl?: string;
darkLogoUrl?: string;
favicon?: string;
slogan?: string;
};
type DeprecatedSignInExperience = {
id: string;
tenantId: string;
branding: DeprecatedBranding;
};
type Branding = {
logoUrl?: string;
darkLogoUrl?: string;
favicon?: string;
};
type SignInExperience = {
id: string;
tenantId: string;
branding: Branding;
};
const alterBranding = async (
signInExperience: DeprecatedSignInExperience,
pool: DatabaseTransactionConnection
) => {
const { id, tenantId, branding: originBranding } = signInExperience;
const {
style, // Extract to remove from branding
slogan, // Extract to remove from branding
logoUrl,
darkLogoUrl,
favicon,
} = originBranding;
const branding: Branding = { logoUrl, darkLogoUrl, favicon };
await pool.query(
sql`update sign_in_experiences set branding = ${JSON.stringify(
branding
)} where id = ${id} and tenant_id = ${tenantId}`
);
};
const rollbackBranding = async (
signInExperience: SignInExperience,
pool: DatabaseTransactionConnection
) => {
const {
id,
tenantId,
branding: { logoUrl, darkLogoUrl, favicon },
} = signInExperience;
const adminBranding: DeprecatedBranding = {
style: DeprecatedBrandingStyle.Logo_Slogan,
slogan: 'admin_console.welcome.title',
logoUrl,
darkLogoUrl,
favicon,
};
const defaultBranding: DeprecatedBranding = {
style: DeprecatedBrandingStyle.Logo,
logoUrl,
darkLogoUrl,
favicon,
};
const branding: DeprecatedBranding = tenantId === 'admin' ? adminBranding : defaultBranding;
await pool.query(
sql`update sign_in_experiences set branding = ${JSON.stringify(
branding
)} where id = ${id} and tenant_id = ${tenantId}`
);
};
const alteration: AlterationScript = {
up: async (pool) => {
const rows = await pool.many<DeprecatedSignInExperience>(
sql`select * from sign_in_experiences`
);
await Promise.all(rows.map(async (row) => alterBranding(row, pool)));
},
down: async (pool) => {
const rows = await pool.many<SignInExperience>(sql`select * from sign_in_experiences`);
await Promise.all(rows.map(async (row) => rollbackBranding(row, pool)));
},
};
export default alteration;

View file

@ -103,16 +103,9 @@ export const colorGuard = z.object({
export type Color = z.infer<typeof colorGuard>;
export enum BrandingStyle {
Logo = 'Logo',
Logo_Slogan = 'Logo_Slogan',
}
export const brandingGuard = z.object({
style: z.nativeEnum(BrandingStyle),
logoUrl: z.string().url(),
logoUrl: z.string().url().optional(),
darkLogoUrl: z.string().url().optional(),
slogan: z.string().optional(),
favicon: z.string().url().optional(),
});

View file

@ -2,7 +2,7 @@ import { generateDarkColor } from '@logto/core-kit';
import type { SignInExperience } from '../db-entries/index.js';
import { SignInMode } from '../db-entries/index.js';
import { BrandingStyle, SignInIdentifier } from '../foundations/index.js';
import { SignInIdentifier } from '../foundations/index.js';
import { adminTenantId, defaultTenantId } from './tenant.js';
const defaultPrimaryColor = '#6139F6';
@ -17,7 +17,6 @@ export const createDefaultSignInExperience = (forTenantId: string): Readonly<Sig
darkPrimaryColor: generateDarkColor(defaultPrimaryColor),
},
branding: {
style: BrandingStyle.Logo,
logoUrl: 'https://logto.io/logo.svg',
darkLogoUrl: 'https://logto.io/logo-dark.svg',
},
@ -60,9 +59,7 @@ export const createAdminTenantSignInExperience = (): Readonly<SignInExperience>
},
signInMode: SignInMode.Register,
branding: {
style: BrandingStyle.Logo_Slogan,
logoUrl: 'https://logto.io/logo.svg',
darkLogoUrl: 'https://logto.io/logo-dark.svg',
slogan: 'admin_console.welcome.title',
},
});

View file

@ -1,11 +1,5 @@
import type { SignInExperience, SignIn } from '@logto/schemas';
import {
BrandingStyle,
ConnectorPlatform,
ConnectorType,
SignInIdentifier,
SignInMode,
} from '@logto/schemas';
import { ConnectorPlatform, ConnectorType, SignInIdentifier, SignInMode } from '@logto/schemas';
import type { SignInExperienceResponse } from '@/types';
@ -190,9 +184,7 @@ export const mockSignInExperience: SignInExperience = {
darkPrimaryColor: '#fff',
},
branding: {
style: BrandingStyle.Logo_Slogan,
logoUrl: 'http://logto.png',
slogan: 'logto',
},
termsOfUseUrl: 'http://terms.of.use/',
privacyPolicyUrl: 'http://privacy.policy/',

View file

@ -1,3 +1,4 @@
import type { Nullable } from '@silverhand/essentials';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import type { TFuncKey } from 'react-i18next';
@ -6,7 +7,7 @@ import * as styles from './index.module.scss';
export type Props = {
className?: string;
logo: string;
logo?: Nullable<string>;
headline?: TFuncKey;
};
@ -15,7 +16,7 @@ const BrandingHeader = ({ logo, headline, className }: Props) => {
return (
<div className={classNames(styles.container, className)}>
<img className={styles.logo} alt="app logo" src={logo} />
{logo && <img className={styles.logo} alt="app logo" src={logo} />}
{headline && <div className={styles.headline}>{t(headline)}</div>}
</div>
);

View file

@ -5,7 +5,7 @@ import type { TFuncKey } from 'react-i18next';
import BrandingHeader from '@/components/BrandingHeader';
import { PageContext } from '@/hooks/use-page-context';
import { getLogoUrl } from '@/utils/logo';
import { getBrandingLogoUrl } from '@/utils/logo';
import AppNotification from '../AppNotification';
import * as styles from './index.module.scss';
@ -23,7 +23,10 @@ const LandingPageContainer = ({ children, className, title }: Props) => {
return null;
}
const { logoUrl, darkLogoUrl } = experienceSettings.branding;
const {
color: { isDarkModeEnabled },
branding,
} = experienceSettings;
return (
<>
@ -32,7 +35,7 @@ const LandingPageContainer = ({ children, className, title }: Props) => {
<BrandingHeader
className={styles.header}
headline={title}
logo={getLogoUrl({ theme, logoUrl, darkLogoUrl })}
logo={getBrandingLogoUrl({ theme, branding, isDarkModeEnabled })}
/>
{children}
</div>

View file

@ -2,17 +2,26 @@ import { ConnectorPlatform } from '@logto/schemas';
import { conditionalString } from '@silverhand/essentials';
import { useEffect, useState } from 'react';
import * as appStyles from '@/containers/AppBoundary/index.module.scss';
import * as styles from '@/containers/AppContent/index.module.scss';
import type { Context } from '@/hooks/use-page-context';
import initI18n from '@/i18n/init';
import { changeLanguage } from '@/i18n/utils';
import type { SignInExperienceResponse, PreviewConfig } from '@/types';
import type { SignInExperienceResponse, PreviewConfig, Theme } from '@/types';
import { parseQueryParameters } from '@/utils';
import { filterPreviewSocialConnectors } from '@/utils/social-connectors';
const applyTheme = (theme: Theme) => {
document.body.classList.remove(
conditionalString(appStyles.light),
conditionalString(appStyles.dark)
);
document.body.classList.add(conditionalString(appStyles[theme]));
};
const usePreview = (context: Context): [boolean, PreviewConfig?] => {
const [previewConfig, setPreviewConfig] = useState<PreviewConfig>();
const { setTheme, setExperienceSettings, setPlatform } = context;
const { setExperienceSettings, setPlatform } = context;
const { preview } = parseQueryParameters(window.location.search);
const isPreview = preview === 'true';
@ -56,7 +65,7 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => {
}
const {
signInExperience: { socialConnectors, color, ...rest },
signInExperience: { socialConnectors, ...rest },
mode,
platform,
isNative,
@ -64,10 +73,6 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => {
const experienceSettings: SignInExperienceResponse = {
...rest,
color: {
...color,
isDarkModeEnabled: false, // Disable theme mode auto detection on preview
},
socialConnectors: filterPreviewSocialConnectors(
isNative ? ConnectorPlatform.Native : ConnectorPlatform.Web,
socialConnectors
@ -75,13 +80,13 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => {
};
(async () => {
setTheme(mode);
applyTheme(mode);
setPlatform(platform);
setExperienceSettings(experienceSettings);
})();
}, [isPreview, previewConfig, setExperienceSettings, setPlatform, setTheme]);
}, [isPreview, previewConfig, setExperienceSettings, setPlatform]);
useEffect(() => {
if (!isPreview || !previewConfig?.language) {

View file

@ -1,3 +1,4 @@
import { conditional } from '@silverhand/essentials';
import { useEffect, useContext, useState } from 'react';
import { consent } from '@/apis/consent';
@ -5,7 +6,7 @@ import { LoadingIcon } from '@/components/LoadingLayer';
import useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler';
import { PageContext } from '@/hooks/use-page-context';
import { getLogoUrl } from '@/utils/logo';
import { getBrandingLogoUrl } from '@/utils/logo';
import * as styles from './index.module.scss';
@ -13,7 +14,13 @@ const Consent = () => {
const { experienceSettings, theme } = useContext(PageContext);
const handleError = useErrorHandler();
const asyncConsent = useApi(consent);
const branding = experienceSettings?.branding;
const { branding, color } = experienceSettings ?? {};
const brandingLogo = conditional(
branding &&
color &&
getBrandingLogoUrl({ theme, branding, isDarkModeEnabled: color.isDarkModeEnabled })
);
const [loading, setLoading] = useState(true);
useEffect(() => {
@ -35,12 +42,7 @@ const Consent = () => {
return (
<div className={styles.wrapper}>
{branding && (
<img
alt="logo"
src={getLogoUrl({ theme, logoUrl: branding.logoUrl, darkLogoUrl: branding.darkLogoUrl })}
/>
)}
{brandingLogo && <img alt="logo" src={brandingLogo} />}
{loading && <LoadingIcon />}
</div>
);

View file

@ -1,3 +1,4 @@
import type { Branding } from '@logto/schemas';
import type { Nullable } from '@silverhand/essentials';
import type { Theme } from '@/types';
@ -16,3 +17,27 @@ export const getLogoUrl = ({ theme, logoUrl, darkLogoUrl, isApple }: GetLogoUrl)
return logoUrl;
};
export type GetBrandingLogoUrl = {
theme: Theme;
branding: Branding;
isDarkModeEnabled: boolean;
};
export const getBrandingLogoUrl = ({ theme, branding, isDarkModeEnabled }: GetBrandingLogoUrl) => {
const { logoUrl, darkLogoUrl } = branding;
if (!isDarkModeEnabled) {
return logoUrl;
}
if (!logoUrl && !darkLogoUrl) {
return null;
}
if (logoUrl && darkLogoUrl) {
return getLogoUrl({ theme, logoUrl, darkLogoUrl });
}
return logoUrl ?? darkLogoUrl;
};