From dc8a4113e2f389d7fc8a25e9a2b0681a5fe2f824 Mon Sep 17 00:00:00 2001 From: Zeke Zhang <958414905@qq.com> Date: Thu, 21 May 2026 16:41:16 +0800 Subject: [PATCH] fix(client-v2): support custom brand integration (#9543) * fix(client-v2): support custom brand integration * fix(client-v2): refine custom brand fallbacks * fix(client-v2): address brand review feedback --- .../core/client-v2/src/BaseApplication.tsx | 6 +- .../core/client-v2/src/__tests__/app.test.tsx | 31 +++++ .../src/css-variable/CSSVariableProvider.tsx | 11 +- .../admin-shell/admin-layout/HelpLite.tsx | 40 +----- packages/core/client-v2/src/hooks/index.ts | 2 + .../client-v2/src/hooks/useCurrentAppInfo.ts | 36 +++++ .../client-v2/src/utils/appVersionHTML.ts | 28 ++++ packages/core/client-v2/src/utils/index.tsx | 2 + .../src/css-variable/CSSVariableProvider.tsx | 8 ++ .../__tests__/PoweredByLite.test.tsx | 123 ++++++++++++++++++ .../client-v2/components/PoweredByLite.tsx | 18 +++ 11 files changed, 268 insertions(+), 37 deletions(-) create mode 100644 packages/core/client-v2/src/hooks/useCurrentAppInfo.ts create mode 100644 packages/core/client-v2/src/utils/appVersionHTML.ts create mode 100644 packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/PoweredByLite.test.tsx diff --git a/packages/core/client-v2/src/BaseApplication.tsx b/packages/core/client-v2/src/BaseApplication.tsx index 52fd36c05df..3c732955eeb 100644 --- a/packages/core/client-v2/src/BaseApplication.tsx +++ b/packages/core/client-v2/src/BaseApplication.tsx @@ -368,11 +368,11 @@ export abstract class BaseApplication< }); } - updateFavicon(favicon?: string) { + updateFavicon(favicon?: string | null) { let faviconLinkElement = document.querySelector('link[rel="shortcut icon"]') as HTMLLinkElement; - if (favicon) { - this.favicon = favicon; + if (arguments.length > 0) { + this.favicon = favicon || ''; } const iconHref = this.favicon || '/favicon/favicon.ico'; diff --git a/packages/core/client-v2/src/__tests__/app.test.tsx b/packages/core/client-v2/src/__tests__/app.test.tsx index adcc48c448c..286305dc5da 100644 --- a/packages/core/client-v2/src/__tests__/app.test.tsx +++ b/packages/core/client-v2/src/__tests__/app.test.tsx @@ -12,6 +12,7 @@ import { useFlowEngineContext } from '@nocobase/flow-engine'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { Outlet } from 'react-router-dom'; +import { escapeHTML, getAppVersionHTML } from '../utils'; const waitForAppReady = async () => { await waitFor(() => { @@ -66,6 +67,36 @@ describe('app', () => { expect(favicon.getAttribute('href')).toBe('/custom-favicon.ico'); }); + it('should reset favicon to default when favicon is cleared', () => { + const app = new Application({ router }); + + app.updateFavicon('/custom-favicon.ico'); + app.updateFavicon(null); + + const favicon = document.querySelector('link[rel="shortcut icon"]') as HTMLLinkElement; + expect(favicon).toBeInTheDocument(); + expect(favicon.getAttribute('href')).toBe('/favicon/favicon.ico'); + }); + + it('should reset favicon to default when favicon is explicitly undefined', () => { + const app = new Application({ router }); + + app.updateFavicon('/custom-favicon.ico'); + app.updateFavicon(undefined); + + const favicon = document.querySelector('link[rel="shortcut icon"]') as HTMLLinkElement; + expect(favicon).toBeInTheDocument(); + expect(favicon.getAttribute('href')).toBe('/favicon/favicon.ico'); + }); + + it('should escape app version html placeholder content', () => { + expect(getAppVersionHTML('&"')).toBe( + 'v<script>alert(1)</script>&"', + ); + expect(getAppVersionHTML(undefined)).toBe(''); + expect(escapeHTML("NocoBase & 'beta'")).toBe('NocoBase <v2> & 'beta''); + }); + it('should reject invalid component objects but keep valid exotic components', () => { const app = new Application({ router }); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/packages/core/client-v2/src/css-variable/CSSVariableProvider.tsx b/packages/core/client-v2/src/css-variable/CSSVariableProvider.tsx index d7af3c55721..0004fc60b74 100644 --- a/packages/core/client-v2/src/css-variable/CSSVariableProvider.tsx +++ b/packages/core/client-v2/src/css-variable/CSSVariableProvider.tsx @@ -10,7 +10,8 @@ import { TinyColor } from '@ctrl/tinycolor'; import { useEffect } from 'react'; import { theme } from 'antd'; -import { type CustomToken, defaultTheme } from '../theme'; +import { defaultTheme } from '../theme'; +import type { CustomToken } from '../theme'; interface Result extends ReturnType { token: CustomToken; @@ -42,6 +43,10 @@ export const CSSVariableProvider = ({ children }) => { document.body.style.setProperty('--colorWarningBg', token.colorWarningBg); document.body.style.setProperty('--colorWarningBorder', token.colorWarningBorder); document.body.style.setProperty('--colorText', token.colorText); + document.body.style.setProperty('--colorTextDescription', token.colorTextDescription); + document.body.style.setProperty('--colorBgTextHover', token.colorBgTextHover); + document.body.style.setProperty('--colorSplit', token.colorSplit); + document.body.style.setProperty('--borderRadiusOuter', `${token.borderRadiusOuter}px`); document.body.style.setProperty('--colorTextHeaderMenu', token.colorTextHeaderMenu); document.body.style.setProperty('--colorPrimaryText', token.colorPrimaryText); document.body.style.setProperty('--colorPrimaryTextActive', token.colorPrimaryTextActive); @@ -81,9 +86,13 @@ export const CSSVariableProvider = ({ children }) => { token.colorPrimaryTextActive, token.colorPrimaryTextHover, token.colorSettings, + token.colorBgTextHover, + token.colorSplit, token.colorText, + token.colorTextDescription, token.colorWarningBg, token.colorWarningBorder, + token.borderRadiusOuter, token.controlHeightLG, token.marginLG, token.marginSM, diff --git a/packages/core/client-v2/src/flow/admin-shell/admin-layout/HelpLite.tsx b/packages/core/client-v2/src/flow/admin-shell/admin-layout/HelpLite.tsx index a2783f5df24..d2fd3599372 100644 --- a/packages/core/client-v2/src/flow/admin-shell/admin-layout/HelpLite.tsx +++ b/packages/core/client-v2/src/flow/admin-shell/admin-layout/HelpLite.tsx @@ -9,45 +9,19 @@ import { QuestionCircleOutlined } from '@ant-design/icons'; import { css } from '@emotion/css'; -import { observer, useFlowEngine } from '@nocobase/flow-engine'; +import { observer } from '@nocobase/flow-engine'; import { parseHTML } from '@nocobase/utils/client'; import { Dropdown, Menu, Popover, theme as antdTheme } from 'antd'; import type { MenuItemType, MenuDividerType } from 'antd/es/menu/interface'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { usePlugin } from '../../../flow-compat'; +import { useCurrentAppInfo } from '../../../hooks'; import type { CustomToken } from '../../../theme'; +import { getAppVersionHTML } from '../../../utils'; type SettingsMenuItemType = MenuItemType | MenuDividerType; -/** - * 读取当前应用信息,避免继续依赖旧的 CurrentAppInfoProvider。 - */ -function useCurrentAppInfoLite() { - const flowEngine = useFlowEngine(); - const [data, setData] = useState(); - - useEffect(() => { - let active = true; - - Promise.resolve(flowEngine.context.appInfo) - .then((info) => { - if (active) { - setData(info); - } - }) - .catch((error) => { - console.error(error); - }); - - return () => { - active = false; - }; - }, [flowEngine]); - - return data; -} - const helpClassName = css` display: inline-block; vertical-align: top; @@ -60,7 +34,7 @@ const helpClassName = css` const SettingsMenu: React.FC = () => { const { t } = useTranslation(); - const appInfo = useCurrentAppInfoLite(); + const appInfo = useCurrentAppInfo(); const { token } = antdTheme.useToken(); const isSimplifiedChinese = appInfo?.lang === 'zh-CN'; @@ -136,7 +110,7 @@ export const HelpLite = observer( const { token } = antdTheme.useToken(); const customToken = token as CustomToken; const customBrandPlugin: any = usePlugin('@nocobase/plugin-custom-brand'); - const appInfo = useCurrentAppInfoLite(); + const appInfo = useCurrentAppInfo(); const icon = ( v${appInfo?.version}`; + const appVersion = getAppVersionHTML(appInfo?.version); const content = parseHTML(customBrandPlugin.options.options.about, { appVersion }); return ( diff --git a/packages/core/client-v2/src/hooks/index.ts b/packages/core/client-v2/src/hooks/index.ts index b2fbaf6c3aa..0a3b978806e 100644 --- a/packages/core/client-v2/src/hooks/index.ts +++ b/packages/core/client-v2/src/hooks/index.ts @@ -8,5 +8,7 @@ */ export * from './useApp'; +export * from './useCurrentAppInfo'; export * from './usePlugin'; export * from './useRouter'; +export { escapeHTML, getAppVersionHTML } from '../utils/appVersionHTML'; diff --git a/packages/core/client-v2/src/hooks/useCurrentAppInfo.ts b/packages/core/client-v2/src/hooks/useCurrentAppInfo.ts new file mode 100644 index 00000000000..a58d0d1bf3c --- /dev/null +++ b/packages/core/client-v2/src/hooks/useCurrentAppInfo.ts @@ -0,0 +1,36 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { useFlowEngineContext } from '@nocobase/flow-engine'; +import { useEffect, useState } from 'react'; + +export function useCurrentAppInfo = Record>() { + const ctx = useFlowEngineContext(); + const [data, setData] = useState(); + + useEffect(() => { + let active = true; + + Promise.resolve(ctx.appInfo) + .then((info) => { + if (active) { + setData((info || {}) as TAppInfo); + } + }) + .catch((error) => { + console.error(error); + }); + + return () => { + active = false; + }; + }, [ctx]); + + return data; +} diff --git a/packages/core/client-v2/src/utils/appVersionHTML.ts b/packages/core/client-v2/src/utils/appVersionHTML.ts new file mode 100644 index 00000000000..33c8fd9e72b --- /dev/null +++ b/packages/core/client-v2/src/utils/appVersionHTML.ts @@ -0,0 +1,28 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +const htmlEscapeMap: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +export function escapeHTML(value: string) { + return value.replace(/[&<>"']/g, (matched) => htmlEscapeMap[matched]); +} + +export function getAppVersionHTML(version: unknown) { + if (version === null || typeof version === 'undefined' || version === '') { + return ''; + } + + return `v${escapeHTML(String(version))}`; +} diff --git a/packages/core/client-v2/src/utils/index.tsx b/packages/core/client-v2/src/utils/index.tsx index 0ed27f43150..47b08961272 100644 --- a/packages/core/client-v2/src/utils/index.tsx +++ b/packages/core/client-v2/src/utils/index.tsx @@ -10,6 +10,8 @@ import React, { ComponentType, FC } from 'react'; import { BlankComponent } from '../components'; +export * from './appVersionHTML'; + export function normalizeContainer(container: Element | ShadowRoot | string): Element | null { if (!container) { console.warn(`Failed to mount app: mount target should not be null or undefined.`); diff --git a/packages/core/client/src/css-variable/CSSVariableProvider.tsx b/packages/core/client/src/css-variable/CSSVariableProvider.tsx index e1ac15a57a0..058d81c96e6 100644 --- a/packages/core/client/src/css-variable/CSSVariableProvider.tsx +++ b/packages/core/client/src/css-variable/CSSVariableProvider.tsx @@ -42,6 +42,10 @@ export const CSSVariableProvider = ({ children }) => { document.body.style.setProperty('--colorWarningBg', token.colorWarningBg); document.body.style.setProperty('--colorWarningBorder', token.colorWarningBorder); document.body.style.setProperty('--colorText', token.colorText); + document.body.style.setProperty('--colorTextDescription', token.colorTextDescription); + document.body.style.setProperty('--colorBgTextHover', token.colorBgTextHover); + document.body.style.setProperty('--colorSplit', token.colorSplit); + document.body.style.setProperty('--borderRadiusOuter', `${token.borderRadiusOuter}px`); document.body.style.setProperty('--colorTextHeaderMenu', token.colorTextHeaderMenu); document.body.style.setProperty('--colorPrimaryText', token.colorPrimaryText); document.body.style.setProperty('--colorPrimaryTextActive', token.colorPrimaryTextActive); @@ -81,9 +85,13 @@ export const CSSVariableProvider = ({ children }) => { token.colorPrimaryTextActive, token.colorPrimaryTextHover, token.colorSettings, + token.colorBgTextHover, + token.colorSplit, token.colorText, + token.colorTextDescription, token.colorWarningBg, token.colorWarningBorder, + token.borderRadiusOuter, token.controlHeightLG, token.marginLG, token.marginSM, diff --git a/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/PoweredByLite.test.tsx b/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/PoweredByLite.test.tsx new file mode 100644 index 00000000000..39cc94b4eba --- /dev/null +++ b/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/PoweredByLite.test.tsx @@ -0,0 +1,123 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { createMockClient, Plugin } from '@nocobase/client-v2'; +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import PoweredByLite from '../components/PoweredByLite'; + +class PoweredByLiteRoutePlugin extends Plugin { + async load() { + this.router.add('root', { + path: '/', + Component: PoweredByLite, + }); + } +} + +class MockCustomBrandPlugin extends Plugin {} + +const renderPoweredByLite = async (plugins: any[] = [], appInfoData: Record = { version: '1.2.3' }) => { + const app = createMockClient({ + plugins: [PoweredByLiteRoutePlugin as any, ...plugins], + }); + + app.apiMock.onGet('app:getInfo').reply(200, { + data: appInfoData, + }); + + const Root = app.getRootComponent(); + const result = render(); + + await waitFor(() => { + expect(document.querySelector('.ant-spin-spinning')).not.toBeInTheDocument(); + }); + + return result; +}; + +describe('PoweredByLite', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should render the default brand when custom-brand is not installed', async () => { + const { container } = await renderPoweredByLite(); + + expect(screen.getByRole('link', { name: 'NocoBase' })).toHaveAttribute('href', 'https://www.nocobase.com'); + expect(container).toHaveTextContent('Powered by NocoBase'); + expect(container.querySelector('.nb-brand')).not.toBeInTheDocument(); + }); + + it('should render custom-brand HTML and replace appVersion', async () => { + const { container } = await renderPoweredByLite([ + [ + MockCustomBrandPlugin, + { + packageName: '@nocobase/plugin-custom-brand', + options: { + brand: 'Custom Brand{{appVersion}}', + }, + }, + ], + ]); + + await waitFor(() => { + expect(container.querySelector('.nb-brand')).toHaveTextContent('Custom Brandv1.2.3'); + }); + expect(container.querySelector('.nb-app-version')).toHaveTextContent('v1.2.3'); + expect(screen.queryByText('Powered by')).not.toBeInTheDocument(); + }); + + it('should not render undefined appVersion when app version is unavailable', async () => { + const { container } = await renderPoweredByLite( + [ + [ + MockCustomBrandPlugin, + { + packageName: '@nocobase/plugin-custom-brand', + options: { + brand: 'Custom Brand{{appVersion}}', + }, + }, + ], + ], + {}, + ); + + expect(container.querySelector('.nb-brand')).toHaveTextContent('Custom Brand'); + expect(container.querySelector('.nb-brand')).not.toHaveTextContent('undefined'); + expect(container.querySelector('.nb-app-version')).not.toBeInTheDocument(); + }); + + it('should escape custom-brand appVersion placeholder', async () => { + const { container } = await renderPoweredByLite( + [ + [ + MockCustomBrandPlugin, + { + packageName: '@nocobase/plugin-custom-brand', + options: { + brand: 'Custom Brand{{appVersion}}', + }, + }, + ], + ], + { + version: '&"', + }, + ); + + await waitFor(() => { + expect(container.querySelector('.nb-app-version')).toHaveTextContent('v&"'); + }); + expect(container.querySelector('.nb-brand script')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-auth/src/client-v2/components/PoweredByLite.tsx b/packages/plugins/@nocobase/plugin-auth/src/client-v2/components/PoweredByLite.tsx index 5d651008c2d..7524af1151a 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client-v2/components/PoweredByLite.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client-v2/components/PoweredByLite.tsx @@ -7,6 +7,8 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import { getAppVersionHTML, useCurrentAppInfo, usePlugin } from '@nocobase/client-v2'; +import { parseHTML } from '@nocobase/utils/client'; import React from 'react'; import { theme as antdTheme } from 'antd'; import { useTranslation } from 'react-i18next'; @@ -14,7 +16,23 @@ import { useTranslation } from 'react-i18next'; export default function PoweredByLite() { const { token } = antdTheme.useToken(); const { i18n } = useTranslation(); + const customBrandPlugin: any = usePlugin('@nocobase/plugin-custom-brand'); + const appInfo = useCurrentAppInfo(); const homePage = i18n.language === 'zh-CN' ? 'https://www.nocobase.com/cn/' : 'https://www.nocobase.com'; + const customBrand = customBrandPlugin?.options?.options?.brand; + + if (customBrand) { + const appVersion = getAppVersionHTML(appInfo?.version); + + return ( +
+ ); + } return (