fix(plugin-license): support license settings in client v2 (#9555)

* fix(plugin-license): support license settings in client v2

* fix(plugin-license): align client-v2 license setting layout

* fix(client-v2): support commercial wrapper plugin entry
This commit is contained in:
Jiann
2026-05-29 16:59:54 +08:00
committed by GitHub
parent 4197f2ec74
commit 1f56a1478b
13 changed files with 715 additions and 13 deletions
@@ -0,0 +1,58 @@
/**
* 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 type { LoaderContext } from '@rspack/core';
import { describe, expect, test } from 'vitest';
import pluginRspackCommercialLoader from '../plugins/pluginRspackCommercialLoader';
type LoaderStub = Pick<LoaderContext<Record<string, unknown>>, 'getOptions' | 'resourcePath'>;
function runLoader(resourcePath: string, source: string, isCommercial = true) {
const context: LoaderStub = {
getOptions: () => ({ isCommercial }),
resourcePath,
};
return pluginRspackCommercialLoader.call(context as LoaderContext<Record<string, unknown>>, source);
}
describe('pluginRspackCommercialLoader', () => {
test('injects commercial wrapper into v1 client index entry', () => {
const source = `
class PluginFoo {}
export default PluginFoo;
`;
const transformed = runLoader('/repo/packages/foo/src/client/index.tsx', source);
expect(transformed).toContain(`import { withCommercial } from '@nocobase/plugin-commercial/client';`);
expect(transformed).toContain('export default withCommercial(PluginFoo);');
});
test('injects commercial wrapper into v2 client plugin entry', () => {
const source = `
class PluginFooV2 {}
export default PluginFooV2;
`;
const transformed = runLoader('/repo/packages/foo/src/client-v2/plugin.tsx', source);
expect(transformed).toContain(`import { withCommercial } from '@nocobase/plugin-commercial/client-v2';`);
expect(transformed).toContain('export default withCommercial(PluginFooV2);');
});
test('does not inject into v2 bridge index entry', () => {
const source = `export { default } from './plugin';`;
const transformed = runLoader('/repo/packages/foo/src/client-v2/index.tsx', source);
expect(transformed).toBe(source);
});
});
@@ -9,15 +9,16 @@
import type { LoaderContext } from '@rspack/core';
export default function myLoader(
this: LoaderContext<Record<string, unknown>>,
source: string,
) {
export default function myLoader(this: LoaderContext<Record<string, unknown>>, source: string) {
const options = this.getOptions();
if (!options?.isCommercial) {
return source;
}
const isEntry = this.resourcePath.match(/client\/index\.(ts|tsx)/) && !this.resourcePath.includes('plugin-commercial');
const isClientV2Plugin =
this.resourcePath.match(/client-v2\/plugin\.(ts|tsx)/) && !this.resourcePath.includes('plugin-commercial');
const isEntry =
(this.resourcePath.match(/client\/index\.(ts|tsx)/) || isClientV2Plugin) &&
!this.resourcePath.includes('plugin-commercial');
if (isEntry) {
const regex = /export\s+default\s+([a-zA-Z_0-9]+)\s*;?/; // match: export default xxx;
@@ -25,9 +26,8 @@ export default function myLoader(
if (match) {
source = source.replace(regex, ``);
const moduleName = match[1];
source =
`
import { withCommercial } from '@nocobase/plugin-commercial/client';
source = `
import { withCommercial } from '@nocobase/plugin-commercial/${isClientV2Plugin ? 'client-v2' : 'client'}';
${source}
export default withCommercial(${moduleName});
`;
@@ -38,4 +38,4 @@ export default function myLoader(
return source;
}
return source;
}
}
@@ -0,0 +1,2 @@
export * from './dist/client-v2';
export { default } from './dist/client-v2';
@@ -0,0 +1 @@
module.exports = require('./dist/client-v2/index.js');
@@ -10,6 +10,7 @@
"description.zh-CN": "实例 ID 和授权密钥设置",
"peerDependencies": {
"@nocobase/client": "2.x",
"@nocobase/client-v2": "2.x",
"@nocobase/server": "2.x",
"@nocobase/test": "2.x"
},
@@ -0,0 +1,90 @@
/**
* 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 { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import LicenseSetting from '../pages/LicenseSetting';
let mountId = 0;
const MockLicenseCard = () => {
const currentMountId = React.useState(() => {
mountId += 1;
return mountId;
})[0];
return <div data-testid="license-card">{currentMountId}</div>;
};
class LicenseSettingRoutePlugin extends Plugin {
async load() {
this.router.add('root', {
path: '/',
Component: LicenseSetting,
});
}
}
describe('LicenseSetting', () => {
afterEach(() => {
cleanup();
mountId = 0;
vi.clearAllMocks();
});
it('should remount the license card after saving a changed key', async () => {
const app = createMockClient({
components: {
LicenseCard: MockLicenseCard,
},
plugins: [LicenseSettingRoutePlugin as any],
});
app.apiMock.onGet('/license:is-exists').reply(200, {
data: true,
});
app.apiMock.onGet('/license:instance-id').reply(200, {
data: 'instance-id-1',
});
app.apiMock.onGet('/license:license-validate').reply(200, {
data: {
licenseStatus: 'active',
isServiceConnection: true,
isPkgLogin: true,
},
});
app.apiMock.onPost('/license:license-key').reply(200, {
data: {
keyStatus: 'valid',
licenseStatus: 'active',
envMatch: true,
domainMatch: true,
isPkgLogin: true,
},
});
const Root = app.getRootComponent();
render(<Root />);
expect(await screen.findByTestId('license-card')).toHaveTextContent('1');
fireEvent.click(screen.getByRole('button', { name: 'Change key' }));
fireEvent.change(screen.getByPlaceholderText('Enter license key'), {
target: { value: 'new-license-key' },
});
fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
await waitFor(() => {
expect(screen.getByTestId('license-card')).toHaveTextContent('2');
});
expect(app.apiMock.history.post.find((request) => request.url === '/license:license-key')).toBeTruthy();
});
});
@@ -0,0 +1,42 @@
/**
* 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 } from '@nocobase/client-v2';
import PluginLicenseClientV2 from '../plugin';
describe('PluginLicenseClientV2', () => {
it('should keep the settings page ACL aligned with the menu ACL', async () => {
const app = createMockClient({
plugins: [
[
PluginLicenseClientV2 as any,
{
name: 'license-settings',
packageName: '@nocobase/plugin-license',
},
],
],
});
await app.load();
expect(app.pluginSettingsManager.get('license-settings')).toMatchObject({
key: 'license-settings',
title: 'License settings',
aclSnippet: 'pm.license-settings',
});
expect(app.pluginSettingsManager.get('license-settings.index')).toMatchObject({
menuKey: 'license-settings',
pageKey: 'index',
title: 'License settings',
aclSnippet: 'pm.license-settings',
componentLoader: expect.any(Function),
});
});
});
@@ -0,0 +1,10 @@
/**
* 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.
*/
export { default } from './plugin';
@@ -0,0 +1,22 @@
/**
* 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 { tExpr as flowTExpr, useFlowEngine } from '@nocobase/flow-engine';
const PACKAGE_NAME = '@nocobase/plugin-license';
export function useT() {
const engine = useFlowEngine();
return (key: string, options?: Record<string, any>) =>
engine.context.t(key, { ns: [PACKAGE_NAME, 'client'], ...options });
}
export function tExpr(key: string) {
return flowTExpr(key, { ns: [PACKAGE_NAME, 'client'] });
}
@@ -0,0 +1,422 @@
/**
* 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 { CopyOutlined } from '@ant-design/icons';
import { useFlowContext } from '@nocobase/flow-engine';
import { Alert, App, Button, Card, Form, Input, Spin } from 'antd';
import type { ComponentType } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useT } from '../locale';
type LicenseValidateResult = {
keyExist?: boolean;
keyStatus?: string;
licenseStatus?: 'active' | 'invalid';
isPkgLogin?: boolean;
isServiceConnection?: boolean;
isExpired?: boolean;
envMatch?: boolean;
dbMatch?: boolean;
sysMatch?: boolean;
domainMatch?: boolean;
current?: {
domain?: string;
};
};
type LicenseFormValues = {
licenseKey?: string;
};
type ApiResponse<T> = {
data?: {
data?: T;
};
};
type LicenseCardComponentProps = {
key?: React.Key;
};
const copyTextToClipboard = async (text: string) => {
if (!text) {
return false;
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (err) {
// Some browsers reject Clipboard API calls after an awaited request.
}
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.setAttribute('readonly', '');
textArea.style.position = 'fixed';
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
return document.execCommand('copy');
} catch (err) {
return false;
} finally {
document.body.removeChild(textArea);
}
};
function LicenseStatusPanel({ refreshToken }: { refreshToken: number }) {
const t = useT();
const { api } = useFlowContext();
const [state, setState] = useState<LicenseValidateResult | null>(null);
useEffect(() => {
let mounted = true;
setState(null);
api
.request({
url: '/license:license-validate',
method: 'get',
})
.then((res) => {
if (mounted) {
setState(res?.data?.data || null);
}
})
.catch((err) => {
console.log(err);
});
return () => {
mounted = false;
};
}, [api, refreshToken]);
const warning = useMemo(() => {
if (!state?.licenseStatus || state.licenseStatus !== 'active') {
return null;
}
if (state.isServiceConnection === false && state.isPkgLogin === false) {
return (
<Alert
message={t('Network error.')}
description={
<>
{t(
'Due to network issues, the license key cannot be updated automatically. Please update it manually if necessary.',
)}
<br />
{t(
'Plugins also cannot be updated automatically (they are still usable). To update plugins, please check your network connection or refer to the NocoBase Service documentation to upload plugins manually.',
)}
</>
}
type="warning"
style={{
marginBottom: 12,
}}
/>
);
}
if (state.isServiceConnection === false) {
return (
<Alert
message={t('Network error.')}
description={t(
'Due to network issues, the license key cannot be updated automatically. Please update it manually if necessary.',
)}
type="warning"
style={{
marginBottom: 12,
}}
/>
);
}
if (state.isPkgLogin === false) {
return (
<Alert
message={t('Network error.')}
description={t(
'Due to network issues, plugins cannot be updated automatically (they are still usable). To update plugins, please check your network connection or refer to the NocoBase Service documentation to upload plugins manually.',
)}
type="warning"
style={{
marginBottom: 12,
}}
/>
);
}
return null;
}, [state, t]);
return warning;
}
export default function LicenseSetting() {
const t = useT();
const { api, app } = useFlowContext();
const { message, modal } = App.useApp();
const [form] = Form.useForm<LicenseFormValues>();
const [checking, setChecking] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [keyExist, setKeyExist] = useState<boolean | null>(null);
const [isEdit, setIsEdit] = useState(false);
const [refreshToken, setRefreshToken] = useState(0);
const [instanceId, setInstanceId] = useState('');
const [copyingInstanceId, setCopyingInstanceId] = useState(false);
useEffect(() => {
let mounted = true;
setChecking(true);
const checkLicenseKey = async () => {
try {
const res = await api.request({
url: '/license:is-exists',
method: 'GET',
});
if (!mounted) {
return;
}
const exists = Boolean(res?.data?.data);
setKeyExist(exists);
setIsEdit(!exists);
} catch (err) {
console.log(err);
} finally {
if (mounted) {
setChecking(false);
}
}
};
checkLicenseKey();
return () => {
mounted = false;
};
}, [api]);
useEffect(() => {
let mounted = true;
api
.request({
url: '/license:instance-id',
method: 'GET',
})
.then((res) => {
if (mounted) {
setInstanceId(String(res?.data?.data || ''));
}
})
.catch((err) => {
console.log(err);
});
return () => {
mounted = false;
};
}, [api]);
const handleCopyInstanceId = useCallback(async () => {
setCopyingInstanceId(true);
try {
let text = instanceId;
if (!text) {
const res = await api.request({
url: '/license:instance-id',
method: 'GET',
});
text = String(res?.data?.data || '');
setInstanceId(text);
}
const copied = await copyTextToClipboard(text);
if (copied) {
message.success(t('Copied'));
return;
}
message.error(t('Failed to copy, please open ./storage/.license/instance-id and copy it'));
} catch (err) {
message.error(t('Failed to copy, please open ./storage/.license/instance-id and copy it'));
} finally {
setCopyingInstanceId(false);
}
}, [api, instanceId, message, t]);
const saveLicenseKey = useCallback(
async (licenseKey: string) => {
setSubmitting(true);
try {
const res = (await api.request({
url: '/license:license-key',
method: 'POST',
data: {
licenseKey,
},
})) as ApiResponse<LicenseValidateResult>;
const licenseValidateResult: LicenseValidateResult = res?.data?.data || {};
if (licenseValidateResult.keyStatus === 'invalid') {
modal.error({
title: t('Invalid license key.'),
content: t('The license key is invalid. Please visit the NocoBase Service to obtain a new license key.'),
});
return;
}
if (licenseValidateResult.licenseStatus === 'invalid') {
modal.error({
title: t('Invalid license key.'),
content: t(
'The current license key has been deprecated. Please visit the NocoBase Service to obtain a new license key.',
),
});
return;
}
if (licenseValidateResult.envMatch === false) {
modal.error({
title: t('Environment mismatch.'),
content: (
<>
{licenseValidateResult.dbMatch === false && licenseValidateResult.sysMatch === false ? (
<>
{t(
'The current system and database do not match the licensed environment. Please use the new InstanceID to request a new license key.',
)}
</>
) : null}
{licenseValidateResult.dbMatch === true && licenseValidateResult.sysMatch === false ? (
<>
{t(
'The current system does not match the licensed system. Please use the new InstanceID to request a new license key.',
)}
</>
) : null}
{licenseValidateResult.dbMatch === false && licenseValidateResult.sysMatch === true ? (
<>
{t(
'The current database does not match the licensed database. Please use the new InstanceID to request a new license key.',
)}
</>
) : null}
</>
),
});
return;
}
if (licenseValidateResult.domainMatch === false) {
modal.error({
title: t('Domain mismatch.'),
content: t(
'The current domain ({{domain}}) does not match the licensed domain. Please use the current domain to request a new license key.',
{
domain: licenseValidateResult.current?.domain,
interpolation: { escapeValue: false },
},
),
});
return;
}
setKeyExist(true);
setIsEdit(false);
setRefreshToken((token) => token + 1);
form.resetFields();
if (licenseValidateResult.isExpired === true) {
message.success(t('The license key was saved successfully'), 5);
modal.warning({
title: t('The license has exceeded the upgrade validity period.'),
content: t(
'Plugins bound to this license can still be used but cannot be upgraded. To upgrade, please renew or repurchase the license.',
),
});
return;
}
if (licenseValidateResult.isPkgLogin === false) {
message.success(t('The license key was saved successfully'), 5);
modal.warning({
title: t('Network error.'),
content: t(
'Due to network issues, plugins cannot be updated automatically (they are still usable). To update plugins, please check your network connection or refer to the NocoBase Service documentation to upload plugins manually.',
),
});
return;
}
message.success(
t(
'The license key has been saved successfully. Please restart the service, and the system will automatically install the plugin.',
),
);
} catch (err) {
message.error(t('Network error. Please try again.'));
} finally {
setSubmitting(false);
}
},
[api, form, message, modal, t],
);
const handleSubmit = useCallback(async () => {
const values = await form.validateFields();
await saveLicenseKey(values.licenseKey);
}, [form, saveLicenseKey]);
const LicenseCard = app.getComponent('LicenseCard', false) as ComponentType<LicenseCardComponentProps> | undefined;
const shouldRenderLicenseCard = Boolean(LicenseCard && keyExist);
return (
<Card bordered={false}>
<Spin spinning={checking}>
<LicenseStatusPanel refreshToken={refreshToken} />
<Form form={form} layout="vertical">
<Form.Item label={t('Instance ID')}>
<Button onClick={handleCopyInstanceId} icon={<CopyOutlined />} type="link" loading={copyingInstanceId}>
{t('Copy')}
</Button>
</Form.Item>
<Form.Item label={t('License key')} required={isEdit}>
{isEdit ? (
<Form.Item name="licenseKey" noStyle rules={[{ required: true, message: t('Enter license key') }]}>
<Input.TextArea rows={4} placeholder={t('Enter license key')} />
</Form.Item>
) : keyExist ? (
<>
{t('License key has been set')}&nbsp;
<Button onClick={() => setIsEdit(true)}>{t('Change key')}</Button>
</>
) : null}
</Form.Item>
{isEdit ? (
<Form.Item>
<Button type="primary" loading={submitting} onClick={handleSubmit}>
{t('Submit')}
</Button>
</Form.Item>
) : null}
</Form>
{shouldRenderLicenseCard ? <LicenseCard key={refreshToken} /> : null}
</Spin>
</Card>
);
}
@@ -0,0 +1,34 @@
/**
* 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 type { Application } from '@nocobase/client-v2';
import { Plugin } from '@nocobase/client-v2';
export class PluginLicenseClientV2 extends Plugin<Record<string, never>, Application> {
async load() {
const title = this.t('License settings') as unknown as string;
this.pluginSettingsManager.addMenuItem({
key: 'license-settings',
title,
icon: 'SolutionOutlined',
aclSnippet: 'pm.license-settings',
});
this.pluginSettingsManager.addPageTabItem({
menuKey: 'license-settings',
key: 'index',
title,
aclSnippet: 'pm.license-settings',
componentLoader: () => import('./pages/LicenseSetting'),
});
}
}
export default PluginLicenseClientV2;
@@ -33,5 +33,15 @@
"Please go to the": "Please go to the",
"License Settings": "License Settings",
"page to enter the license key.": "page to enter the license key.",
"License information": "License information"
}
"License information": "License information",
"Status": "Status",
"Active": "Active",
"Invalid": "Invalid",
"Current domain": "Current domain",
"Service connection": "Service connection",
"Plugin service login": "Plugin service login",
"Connected": "Connected",
"Disconnected": "Disconnected",
"Submit": "Submit",
"Network error. Please try again.": "Network error. Please try again."
}
@@ -33,5 +33,15 @@
"Please go to the": "请前往",
"License Settings": "授权设置",
"page to enter the license key.": "页面填写授权密钥。",
"License information": "授权信息"
}
"License information": "授权信息",
"Status": "状态",
"Active": "有效",
"Invalid": "无效",
"Current domain": "当前域名",
"Service connection": "服务连接",
"Plugin service login": "插件服务登录",
"Connected": "已连接",
"Disconnected": "未连接",
"Submit": "提交",
"Network error. Please try again.": "网络错误,请重试。"
}