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 (