diff --git a/.changeset-staged/eight-shoes-look.md b/.changeset-staged/eight-shoes-look.md new file mode 100644 index 000000000..a272c4e2b --- /dev/null +++ b/.changeset-staged/eight-shoes-look.md @@ -0,0 +1,17 @@ +--- +"@logto/console": minor +"@logto/phrases": minor +"@logto/phrases-ui": minor +"@logto/schemas": minor +"@logto/ui": minor +--- + +### Add dynamic favicon and html title + +- Add the favicon field in the sign-in-experience branding settings. Users would be able to upload their own favicon. Use local logto icon as a fallback + +- Set different html title for different pages. + - sign-in + - register + - forgot-password + - logto diff --git a/packages/console/src/pages/SignInExperience/tabs/Branding/BrandingForm.tsx b/packages/console/src/pages/SignInExperience/tabs/Branding/BrandingForm.tsx index 787662fa0..8d2eb33d9 100644 --- a/packages/console/src/pages/SignInExperience/tabs/Branding/BrandingForm.tsx +++ b/packages/console/src/pages/SignInExperience/tabs/Branding/BrandingForm.tsx @@ -27,6 +27,16 @@ const BrandingForm = () => { return (
{t('sign_in_exp.branding.title')}
+ + !value || uriValidator(value) || t('errors.invalid_uri_format'), + })} + hasError={Boolean(errors.branding?.favicon)} + errorMessage={errors.branding?.favicon?.message} + placeholder={t('sign_in_exp.branding.favicon')} + /> + .', terms_of_use: 'Nutzungsbedingungen', + sign_in: 'Anmelden', create_account: 'Konto erstellen', or: 'oder', enter_passcode: 'Der Bestätigungscode wurde an deine {{address}} gesendet', diff --git a/packages/phrases-ui/src/locales/en.ts b/packages/phrases-ui/src/locales/en.ts index bd22b1d15..5305392ca 100644 --- a/packages/phrases-ui/src/locales/en.ts +++ b/packages/phrases-ui/src/locales/en.ts @@ -46,6 +46,7 @@ const translation = { agree_with_terms: 'I have read and agree to the ', agree_with_terms_modal: 'To proceed, please agree to the .', terms_of_use: 'Terms of Use', + sign_in: 'Sign in', create_account: 'Create account', or: 'or', enter_passcode: 'The verification code has been sent to your {{address}} {{target}}', diff --git a/packages/phrases-ui/src/locales/fr.ts b/packages/phrases-ui/src/locales/fr.ts index fd48d9918..aaebb65e2 100644 --- a/packages/phrases-ui/src/locales/fr.ts +++ b/packages/phrases-ui/src/locales/fr.ts @@ -48,6 +48,7 @@ const translation = { agree_with_terms: "J'ai lu et accepté les ", agree_with_terms_modal: 'Pour continuer, veuillez accepter le .', terms_of_use: "Conditions d'utilisation", + sign_in: 'Connexion', create_account: 'Créer un compte', or: 'ou', enter_passcode: 'Le code a été envoyé à {{address}} {{target}}', diff --git a/packages/phrases-ui/src/locales/ko.ts b/packages/phrases-ui/src/locales/ko.ts index 2a5e6a406..4b6b8d324 100644 --- a/packages/phrases-ui/src/locales/ko.ts +++ b/packages/phrases-ui/src/locales/ko.ts @@ -48,6 +48,7 @@ const translation = { agree_with_terms: '나는 내용을 읽었으며, 이에 동의합니다.', agree_with_terms_modal: '진행하기 위해서는, 다음을 동의해주세요 .', terms_of_use: '이용약관', + sign_in: '로그인', create_account: '계정 생성', or: '또는', enter_passcode: '{{address}} {{target}} 으로 비밀번호가 전송되었어요.', diff --git a/packages/phrases-ui/src/locales/pt-br.ts b/packages/phrases-ui/src/locales/pt-br.ts index c2106d2b0..841f6025f 100644 --- a/packages/phrases-ui/src/locales/pt-br.ts +++ b/packages/phrases-ui/src/locales/pt-br.ts @@ -48,6 +48,7 @@ const translation = { agree_with_terms: 'Eu li e concordo com os ', agree_with_terms_modal: 'Para continuar, por favor, concorde com os .', terms_of_use: 'Termos de uso', + sign_in: 'Entrar', create_account: 'Criar conta', or: 'ou', enter_passcode: 'O código de verificação foi enviado para o seu {{address}} {{target}}', diff --git a/packages/phrases-ui/src/locales/pt-pt.ts b/packages/phrases-ui/src/locales/pt-pt.ts index ade6f63cb..a7a99fb8e 100644 --- a/packages/phrases-ui/src/locales/pt-pt.ts +++ b/packages/phrases-ui/src/locales/pt-pt.ts @@ -48,6 +48,7 @@ const translation = { agree_with_terms: 'Eu li e concordo com os ', agree_with_terms_modal: 'Para prosseguir, por favor, concorde com o .', terms_of_use: 'Termos de uso', + sign_in: 'Entrar', create_account: 'Criar uma conta', or: 'ou', enter_passcode: 'A senha foi enviada para o seu {{address}} {{target}}', diff --git a/packages/phrases-ui/src/locales/ru.ts b/packages/phrases-ui/src/locales/ru.ts index 56f207f41..865ca9f39 100644 --- a/packages/phrases-ui/src/locales/ru.ts +++ b/packages/phrases-ui/src/locales/ru.ts @@ -48,6 +48,7 @@ const translation = { agree_with_terms: 'Я прочитал и согласен с ', agree_with_terms_modal: 'Чтобы продолжить, пожалуйста, согласитесь с ', terms_of_use: 'Условиями использования', + sign_in: 'Войти', create_account: 'Создать аккаунт', or: 'или', enter_passcode: 'Код подтверждения был отправлен на {{address}}', diff --git a/packages/phrases-ui/src/locales/tr-tr.ts b/packages/phrases-ui/src/locales/tr-tr.ts index 65feb618b..ba8000d61 100644 --- a/packages/phrases-ui/src/locales/tr-tr.ts +++ b/packages/phrases-ui/src/locales/tr-tr.ts @@ -48,6 +48,7 @@ const translation = { agree_with_terms: 'Okudum ve anladım', agree_with_terms_modal: 'Devam etmek için lütfen i kabul edin.', terms_of_use: 'Kullanım Koşulları', + sign_in: 'Giriş Yap', create_account: 'Hesap Oluştur', or: 'veya', enter_passcode: 'Kod {{address}} {{target}} inize gönderildi.', diff --git a/packages/phrases-ui/src/locales/zh-cn.ts b/packages/phrases-ui/src/locales/zh-cn.ts index d9425779a..1a0bd775c 100644 --- a/packages/phrases-ui/src/locales/zh-cn.ts +++ b/packages/phrases-ui/src/locales/zh-cn.ts @@ -48,6 +48,7 @@ const translation = { agree_with_terms: '我已阅读并同意 ', agree_with_terms_modal: '请先同意 以继续', terms_of_use: '使用条款', + sign_in: '登录', create_account: '创建帐号', or: '或', enter_passcode: '验证码已经发送至你的{{ address }} {{target}}', diff --git a/packages/phrases/src/locales/de/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/de/translation/admin-console/sign-in-exp.ts index 59c774587..228c74fe6 100644 --- a/packages/phrases/src/locales/de/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/de/translation/admin-console/sign-in-exp.ts @@ -84,6 +84,7 @@ const sign_in_exp = { branding: { title: 'BRANDING', ui_style: 'Stil', + favicon: 'Browser favicon', // UNTRANSLATED styles: { logo_slogan: 'App logo mit Slogan', logo: 'Nur App logo', diff --git a/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp.ts index ee41c80a2..09ddcd657 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp.ts @@ -27,6 +27,7 @@ const sign_in_exp = { branding: { title: 'BRANDING AREA', ui_style: 'Style', + favicon: 'Browser favicon', styles: { logo_slogan: 'App logo with slogan', logo: 'App logo only', diff --git a/packages/phrases/src/locales/fr/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/fr/translation/admin-console/sign-in-exp.ts index c5928e19c..44bd7e6fe 100644 --- a/packages/phrases/src/locales/fr/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/fr/translation/admin-console/sign-in-exp.ts @@ -29,6 +29,7 @@ const sign_in_exp = { branding: { 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", diff --git a/packages/phrases/src/locales/ko/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/ko/translation/admin-console/sign-in-exp.ts index 3ab9e9c55..88ef6c449 100644 --- a/packages/phrases/src/locales/ko/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/ko/translation/admin-console/sign-in-exp.ts @@ -25,6 +25,7 @@ const sign_in_exp = { branding: { title: '브랜딩 영역', ui_style: '스타일', + favicon: 'Browser favicon', // UNTRANSLATED styles: { logo_slogan: '앱 로고 & 슬로건', logo: '앱 로고만', diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/sign-in-exp.ts index 9a1cfd65b..a420e8c55 100644 --- a/packages/phrases/src/locales/pt-br/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/sign-in-exp.ts @@ -28,6 +28,7 @@ const sign_in_exp = { branding: { title: 'ÁREA DE MARCA', ui_style: 'Estilo', + favicon: 'Browser favicon', // UNTRANSLATED styles: { logo_slogan: 'Logo do aplicativo com slogan', logo: 'Somente logotipo do aplicativo', diff --git a/packages/phrases/src/locales/pt-pt/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/pt-pt/translation/admin-console/sign-in-exp.ts index 430925197..25c01fd09 100644 --- a/packages/phrases/src/locales/pt-pt/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/pt-pt/translation/admin-console/sign-in-exp.ts @@ -27,6 +27,7 @@ const sign_in_exp = { branding: { 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', diff --git a/packages/phrases/src/locales/tr-tr/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/tr-tr/translation/admin-console/sign-in-exp.ts index bf18e9e1b..0f9a202ed 100644 --- a/packages/phrases/src/locales/tr-tr/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/tr-tr/translation/admin-console/sign-in-exp.ts @@ -28,6 +28,7 @@ const sign_in_exp = { branding: { title: 'MARKA ALANI', ui_style: 'Stil', + favicon: 'Browser favicon', // UNTRANSLATED styles: { logo_slogan: 'Sloganlı şekilde uygulama logosu', logo: 'Yalnızca uygulama logosu', diff --git a/packages/phrases/src/locales/zh-cn/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/zh-cn/translation/admin-console/sign-in-exp.ts index e4df5e62d..c9cf49fb7 100644 --- a/packages/phrases/src/locales/zh-cn/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/zh-cn/translation/admin-console/sign-in-exp.ts @@ -26,6 +26,7 @@ const sign_in_exp = { branding: { title: '品牌定制区', ui_style: '样式', + favicon: '浏览器地址栏图标', styles: { logo_slogan: 'Logo 和标语', logo: '仅有 Logo', diff --git a/packages/schemas/src/foundations/jsonb-types.ts b/packages/schemas/src/foundations/jsonb-types.ts index 52192966e..57e46007d 100644 --- a/packages/schemas/src/foundations/jsonb-types.ts +++ b/packages/schemas/src/foundations/jsonb-types.ts @@ -113,6 +113,7 @@ export const brandingGuard = z.object({ logoUrl: z.string().url(), darkLogoUrl: z.string().url().optional(), slogan: z.string().optional(), + favicon: z.string().url().optional(), }); export type Branding = z.infer; diff --git a/packages/ui/jest.config.ts b/packages/ui/jest.config.ts index 2a12e9672..acb36ac41 100644 --- a/packages/ui/jest.config.ts +++ b/packages/ui/jest.config.ts @@ -7,6 +7,7 @@ const config: Config.InitialOptions = { setupFilesAfterEnv: ['/src/jest.setup.ts'], transform: { '\\.(svg)$': 'jest-transformer-svg', + '\\.(png)$': 'jest-transform-stub', }, }), // Will update common config soon diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index d94149f65..8db3ea361 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -23,7 +23,7 @@ import SocialLinkAccount from './pages/SocialLinkAccount'; import SocialSignIn from './pages/SocialSignInCallback'; import Springboard from './pages/Springboard'; import VerificationCode from './pages/VerificationCode'; -import { getSignInExperienceSettings } from './utils/sign-in-experience'; +import { getSignInExperienceSettings, setFavIcon } from './utils/sign-in-experience'; import './scss/normalized.scss'; @@ -44,8 +44,15 @@ const App = () => { (async () => { const settings = await getSignInExperienceSettings(); + + const { + customCss, + branding: { favicon }, + } = settings; + // eslint-disable-next-line @silverhand/fp/no-mutation - customCssRef.current.textContent = settings.customCss; + customCssRef.current.textContent = customCss; + setFavIcon(favicon); // Note: i18n must be initialized ahead of page render await initI18n(); diff --git a/packages/ui/src/apple-touch-icon.png b/packages/ui/src/apple-touch-icon.png deleted file mode 100644 index 893bb266f..000000000 Binary files a/packages/ui/src/apple-touch-icon.png and /dev/null differ diff --git a/packages/ui/src/assets/apple-touch-icon.png b/packages/ui/src/assets/apple-touch-icon.png new file mode 100644 index 000000000..1afa02e81 Binary files /dev/null and b/packages/ui/src/assets/apple-touch-icon.png differ diff --git a/packages/ui/src/assets/favicon.png b/packages/ui/src/assets/favicon.png new file mode 100644 index 000000000..9d84f144b Binary files /dev/null and b/packages/ui/src/assets/favicon.png differ diff --git a/packages/ui/src/containers/AppContent/index.tsx b/packages/ui/src/containers/AppContent/index.tsx index b4f191005..876e54100 100644 --- a/packages/ui/src/containers/AppContent/index.tsx +++ b/packages/ui/src/containers/AppContent/index.tsx @@ -1,12 +1,25 @@ -import { Outlet } from 'react-router-dom'; +import { useEffect } from 'react'; +import { Outlet, useLocation } from 'react-router-dom'; import LogtoSignature from '@/components/LogtoSignature'; import usePlatform from '@/hooks/use-platform'; +import { parseHtmlTitle } from '@/utils/sign-in-experience'; import * as styles from './index.module.scss'; const AppContent = () => { const { isMobile } = usePlatform(); + const location = useLocation(); + + useEffect(() => { + const { pathname } = location; + const title = parseHtmlTitle(pathname); + + if (title) { + // eslint-disable-next-line @silverhand/fp/no-mutation + document.title = title; + } + }, [location]); return (
diff --git a/packages/ui/src/hooks/use-preview.ts b/packages/ui/src/hooks/use-preview.ts index 7bfad88d0..753481dea 100644 --- a/packages/ui/src/hooks/use-preview.ts +++ b/packages/ui/src/hooks/use-preview.ts @@ -57,7 +57,6 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => { const { signInExperience: { socialConnectors, color, ...rest }, - language, mode, platform, isNative, diff --git a/packages/ui/src/index.html b/packages/ui/src/index.html index ed24fa51d..983769dcd 100644 --- a/packages/ui/src/index.html +++ b/packages/ui/src/index.html @@ -4,8 +4,6 @@ - - Logto diff --git a/packages/ui/src/utils/sign-in-experience.ts b/packages/ui/src/utils/sign-in-experience.ts index eecd56058..b4ad61ccb 100644 --- a/packages/ui/src/utils/sign-in-experience.ts +++ b/packages/ui/src/utils/sign-in-experience.ts @@ -4,8 +4,12 @@ */ import { SignInIdentifier } from '@logto/schemas'; +import i18next from 'i18next'; +import type { TFuncKey } from 'react-i18next'; import { getSignInExperience } from '@/apis/settings'; +import defaultAppleTouchLogo from '@/assets/apple-touch-icon.png'; +import defaultFavicon from '@/assets/favicon.png'; import type { SignInExperienceResponse } from '@/types'; import { filterSocialConnectors } from '@/utils/social-connectors'; @@ -30,3 +34,54 @@ export const isEmailOrPhoneMethod = ( method: SignInIdentifier ): method is SignInIdentifier.Email | SignInIdentifier.Phone => [SignInIdentifier.Email, SignInIdentifier.Phone].includes(method); + +export const parseHtmlTitle = (path: string) => { + if (path.startsWith('/sign-in') || path.startsWith('/callback') || path.startsWith('/consent')) { + return i18next.t<'translation', TFuncKey>('description.sign_in'); + } + + if (path.startsWith('/register') || path.startsWith('/social/link')) { + return i18next.t<'translation', TFuncKey>('description.create_account'); + } + + if (path.startsWith('/forgot-password')) { + return i18next.t<'translation', TFuncKey>('description.reset_password'); + } + + // Return undefined for all continue flow pages to keep title remain the same as the previous page + if (path.startsWith('/continue')) { + return; + } + + return 'Logto'; +}; + +export const setFavIcon = (icon?: string) => { + const iconLink = document.querySelector('link[rel="icon"]'); + const appleTouchIconLink = document.querySelector( + 'link[rel="apple-touch-icon"]' + ); + + /* eslint-disable @silverhand/fp/no-mutation */ + + if (iconLink) { + iconLink.href = icon ?? defaultFavicon; + } else { + const favIconLink = document.createElement('link'); + favIconLink.rel = 'shortcut icon'; + favIconLink.href = icon ?? defaultFavicon; + document.querySelectorAll('head')[0]?.append(favIconLink); + } + + if (appleTouchIconLink) { + appleTouchIconLink.href = icon ?? defaultAppleTouchLogo; + } else { + const appleTouchIconLink = document.createElement('link'); + appleTouchIconLink.rel = 'apple-touch-icon'; + appleTouchIconLink.href = icon ?? defaultAppleTouchLogo; + appleTouchIconLink.setAttribute('sizes', '180x180'); + document.querySelectorAll('head')[0]?.append(appleTouchIconLink); + } + + /* eslint-enable @silverhand/fp/no-mutation */ +};