Feat/commercial (#6554)

* feat: add inst commond

* feat: add inst commond and pkg commond support .key

* fix: change isntanceid and key path

* feat: support commerical plugin server inject

* feat: support commerical for client build

* feat: add compatibility code

* fix: commercial plugin build

* fix: build server/index

* feat: add defaut txt match

* feat: open obfuscate

* feat: update license-kit 0.2.3

* fix: build include plugin-commercial

* fix: plugin-commercial build

* fix: test not include commercial

* fix: remove log

* fix: add license-kit to external

* fix: obfuscate build

* fix: client build external

* fix: commercial build client

* fix: plugin build just encrypt index

* fix: instance id add line break

* feat: add view-license-ke commond

* feat: add upgrade to license

* fix: change license key name

* fix: pro plugin build

* fix: remove tip in plugin page

* fix: field rename

* fix: remove unused code

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Jiann
2025-05-11 16:06:13 +08:00
committed by GitHub
co-authored by chenos
parent 6f895d05e8
commit d94b228f39
29 changed files with 613 additions and 184 deletions
+4 -1
View File
@@ -26,12 +26,14 @@
"@vercel/ncc": "0.36.1",
"babel-loader": "^9.2.1",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"bundle-require": "^5.1.0",
"chalk": "2.4.2",
"css-loader": "^6.8.1",
"esbuild-register": "^3.4.2",
"fast-glob": "^3.3.1",
"gulp": "4.0.2",
"gulp-typescript": "6.0.0-alpha.1",
"javascript-obfuscator": "^4.1.1",
"less": "^4.2.0",
"less-loader": "^12.2.0",
"postcss": "^8.4.29",
@@ -49,7 +51,8 @@
},
"license": "AGPL-3.0",
"scripts": {
"build": "tsup"
"build": "tsup",
"build:watch": "tsup --watch"
},
"gitHead": "d0b4efe4be55f8c79a98a331d99d9f8cf99021a1"
}
+153 -23
View File
@@ -14,9 +14,8 @@ import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
import { build as tsupBuild } from 'tsup';
import { RsdoctorRspackPlugin } from '@rsdoctor/rspack-plugin';
import { EsbuildSupportExts, globExcludeFiles } from './constant';
import * as bundleRequire from 'bundle-require';
import { EsbuildSupportExts, globExcludeFiles, PLUGIN_COMMERCIAL } from './constant';
import { PkgLog, UserConfig, getPackageJson } from './utils';
import {
buildCheck,
@@ -27,6 +26,9 @@ import {
getSourcePackages,
} from './utils/buildPluginUtils';
import { getDepPkgPath, getDepsConfig } from './utils/getDepsConfig';
import { RsdoctorRspackPlugin } from '@rsdoctor/rspack-plugin';
import { obfuscate } from './utils/obfuscationResult';
import pluginEsbuildCommercialInject from './plugins/pluginEsbuildCommercialInject';
const validExts = ['.ts', '.tsx', '.js', '.jsx', '.mjs'];
const serverGlobalFiles: string[] = ['src/**', '!src/client/**', ...globExcludeFiles];
@@ -55,6 +57,7 @@ const external = [
'@nocobase/server',
'@nocobase/test',
'@nocobase/utils',
'@nocobase/license-kit',
// @nocobase/auth
'jsonwebtoken',
@@ -311,10 +314,112 @@ export async function buildPluginServer(cwd: string, userConfig: UserConfig, sou
await buildServerDeps(cwd, serverFiles, log);
}
export async function buildPluginClient(cwd: string, userConfig: UserConfig, sourcemap: boolean, log: PkgLog) {
export async function buildProPluginServer(cwd: string, userConfig: UserConfig, sourcemap: boolean, log: PkgLog) {
log('build pro plugin server source');
const packageJson = getPackageJson(cwd);
const serverFiles = fg.globSync(serverGlobalFiles, { cwd, absolute: true });
buildCheck({ cwd, packageJson, entry: 'server', files: serverFiles, log });
const otherExts = Array.from(
new Set(serverFiles.map((item) => path.extname(item)).filter((item) => !EsbuildSupportExts.includes(item))),
);
if (otherExts.length) {
log('%s will not be processed, only be copied to the dist directory.', chalk.yellow(otherExts.join(',')));
}
deleteServerFiles(cwd, log);
// remove compilerOptions.paths in tsconfig.json
let tsconfig = bundleRequire.loadTsConfig(path.join(cwd, 'tsconfig.json'));
fs.writeFileSync(path.join(cwd, 'tsconfig.json'), JSON.stringify({
...tsconfig.data,
compilerOptions: { ...tsconfig.data.compilerOptions, paths: [] }
}, null, 2));
tsconfig = bundleRequire.loadTsConfig(path.join(cwd, 'tsconfig.json'));
// convert all ts to js, some files may not be referenced by the entry file
await tsupBuild(
userConfig.modifyTsupConfig({
entry: serverFiles,
splitting: false,
clean: false,
bundle: false,
silent: true,
treeshake: false,
target: 'node16',
sourcemap,
outDir: path.join(cwd, target_dir),
format: 'cjs',
skipNodeModulesBundle: true,
loader: {
...otherExts.reduce((prev, cur) => ({ ...prev, [cur]: 'copy' }), {}),
'.json': 'copy',
},
}),
);
const entryFile = path.join(cwd, 'src/server/index.ts');
if (!fs.existsSync(entryFile)) {
log('server entry file not found', entryFile);
return;
}
// plugin-commercial build to a bundle
const externalOptions = {
external: [],
noExternal: [],
onSuccess: async () => {},
esbuildPlugins: [],
};
// other plugins build to a bundle just include plugin-commercial
if (!cwd.includes(PLUGIN_COMMERCIAL)) {
externalOptions.external = [/^[./]/];
externalOptions.noExternal = [entryFile, /@nocobase\/plugin-commercial\/server/, /dist\/server\/index\.js/];
externalOptions.onSuccess = async () => {
const serverFiles = [path.join(cwd, target_dir, 'server', 'index.js')];
serverFiles.forEach((file) => {
obfuscate(file);
});
};
externalOptions.esbuildPlugins = [pluginEsbuildCommercialInject];
}
// bundle all files、inject commercial code and obfuscate
await tsupBuild(
userConfig.modifyTsupConfig({
entry: [entryFile],
// minify: true,
splitting: false,
clean: false,
bundle: true,
silent: true,
treeshake: false,
target: 'node16',
sourcemap,
outDir: path.join(cwd, target_dir, 'server'),
format: 'cjs',
skipNodeModulesBundle: true,
tsconfig: tsconfig.path,
loader: {
...otherExts.reduce((prev, cur) => ({ ...prev, [cur]: 'copy' }), {}),
'.json': 'copy',
},
...externalOptions,
}),
);
fs.removeSync(tsconfig.path);
await buildServerDeps(cwd, serverFiles, log);
}
export async function buildPluginClient(cwd: string, userConfig: any, sourcemap: boolean, log: PkgLog, isCommercial = false) {
log('build plugin client');
const packageJson = getPackageJson(cwd);
const clientFiles = fg.globSync(clientGlobalFiles, { cwd, absolute: true });
if (isCommercial) {
const commercialFiles = fg.globSync(clientGlobalFiles, { cwd: path.join(process.cwd(), 'packages/pro-plugins', PLUGIN_COMMERCIAL), absolute: true });
clientFiles.push(...commercialFiles);
}
const clientFileSource = clientFiles.map((item) => fs.readFileSync(item, 'utf-8'));
const sourcePackages = getPackagesFromFiles(clientFileSource);
const excludePackages = getExcludePackages(sourcePackages, external, pluginPrefix);
@@ -440,31 +545,51 @@ export async function buildPluginClient(cwd: string, userConfig: UserConfig, sou
{
test: /\.tsx$/,
exclude: /[\\/]node_modules[\\/]/,
loader: 'builtin:swc-loader',
options: {
sourceMap: true,
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
use: [
{
loader: 'builtin:swc-loader',
options: {
sourceMap: true,
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
},
target: 'es5',
},
},
target: 'es5',
},
},
{
loader: require.resolve('./plugins/pluginRspackCommercialLoader'),
options: {
isCommercial
}
}
]
},
{
test: /\.ts$/,
exclude: /[\\/]node_modules[\\/]/,
loader: 'builtin:swc-loader',
options: {
sourceMap: true,
jsc: {
parser: {
syntax: 'typescript',
use: [
{
loader: 'builtin:swc-loader',
options: {
sourceMap: true,
jsc: {
parser: {
syntax: 'typescript',
},
target: 'es5',
},
},
target: 'es5',
},
},
{
loader: require.resolve('./plugins/pluginRspackCommercialLoader'),
options: {
isCommercial
}
}
]
},
],
},
@@ -571,7 +696,12 @@ __webpack_require__.p = (function() {
}
export async function buildPlugin(cwd: string, userConfig: UserConfig, sourcemap: boolean, log: PkgLog) {
await buildPluginClient(cwd, userConfig, sourcemap, log);
await buildPluginServer(cwd, userConfig, sourcemap, log);
if (cwd.includes('/pro-plugins/') && fs.existsSync(path.join(process.cwd(), 'packages/pro-plugins/', PLUGIN_COMMERCIAL))) {
await buildPluginClient(cwd, userConfig, sourcemap, log, true);
await buildProPluginServer(cwd, userConfig, sourcemap, log);
} else {
await buildPluginClient(cwd, userConfig, sourcemap, log);
await buildPluginServer(cwd, userConfig, sourcemap, log);
}
writeExternalPackageVersion(cwd, log);
}
+4 -1
View File
@@ -43,8 +43,11 @@ export const PLUGINS_DIR = ['plugins', 'samples', 'pro-plugins']
.filter(Boolean)
.map((name) => path.join(PACKAGES_PATH, name));
export const PRESETS_DIR = path.join(PACKAGES_PATH, 'presets');
export const PLUGIN_COMMERCIAL = '@nocobase/plugin-commercial';
export const getPluginPackages = (packages: Package[]) =>
packages.filter((item) => PLUGINS_DIR.some((pluginDir) => item.location.startsWith(pluginDir)));
packages.filter((item) => PLUGINS_DIR.some((pluginDir) => item.location.startsWith(pluginDir))).sort((a, b) => {
return a.name === PLUGIN_COMMERCIAL ? -1 : 1;
});
export const getPresetsPackages = (packages: Package[]) =>
packages.filter((item) => item.location.startsWith(PRESETS_DIR));
export const CORE_APP = path.join(PACKAGES_PATH, 'core/app');
@@ -0,0 +1,54 @@
/**
* 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 fs from 'node:fs'
const pluginEsbuildCommercialInject = {
name: 'plugin-esbuild-commercial-inject',
setup(build) {
build.onLoad({ filter: /src\/server\/index\.ts$/ }, async (args) => {
let source = fs.readFileSync(args.path, 'utf8');
const regex = /export\s*\{\s*default\s*\}\s*from\s*(?:'([^']*)'|"([^"]*)");?/; // match: export { default } from './plugin';
const regex2 = /export\s+default\s+([a-zA-Z_0-9]+)\s*;?/; // match: export default xxx;
const match = source.match(regex);
const match2 = source.match(regex2);
if (match) {
source = source.replace(regex, ``);
const moduleName = match[1] || match[2];
source =
`
import { withCommercial } from '@nocobase/plugin-commercial/server';
import _plugin from '${moduleName}';
export default withCommercial(_plugin);
${source}
`;
console.log(`Insert commercial server code success`);
} else if (match2) {
source = source.replace(regex2, ``);
const moduleName = match2[1] || match2[2];
source =
`
import { withCommercial } from '@nocobase/plugin-commercial/server';
${source}
export default withCommercial(${moduleName});
`;
console.log(`Insert commercial server code success`);
} else {
console.error(`Insert commercial server code fail`);
}
return {
contents: source,
loader: 'ts',
}
})
},
};
export default pluginEsbuildCommercialInject
@@ -0,0 +1,41 @@
/**
* 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';
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');
if (isEntry) {
const regex = /export\s+default\s+([a-zA-Z_0-9]+)\s*;?/; // match: export default xxx;
const match = source.match(regex);
if (match) {
source = source.replace(regex, ``);
const moduleName = match[1];
source =
`
import { withCommercial } from '@nocobase/plugin-commercial/client';
${source}
export default withCommercial(${moduleName});
`;
console.log(`Insert commercial client code success`);
} else {
console.error(`Insert commercial client code fail`);
}
return source;
}
return source;
}
@@ -0,0 +1,43 @@
/**
* 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 fs from 'fs-extra';
import * as JavaScriptObfuscator from 'javascript-obfuscator';
export const obfuscate = (filePath: string) => {
const fileContent = fs.readFileSync(filePath, 'utf8');
const obfuscationResult = JavaScriptObfuscator.obfuscate(fileContent, {
compact: true,
controlFlowFlattening: false,
deadCodeInjection: false,
debugProtection: false,
debugProtectionInterval: 0,
disableConsoleOutput: true,
identifierNamesGenerator: 'hexadecimal',
log: false,
numbersToExpressions: false,
renameGlobals: false,
selfDefending: true,
simplify: true,
splitStrings: false,
stringArray: true,
stringArrayCallsTransform: false,
stringArrayEncoding: [],
stringArrayIndexShift: true,
stringArrayRotate: true,
stringArrayShuffle: true,
stringArrayWrappersCount: 1,
stringArrayWrappersChainedCalls: true,
stringArrayWrappersParametersMaxCount: 2,
stringArrayWrappersType: 'variable',
stringArrayThreshold: 0.75,
unicodeEscapeSequence: false
});
fs.writeFileSync(filePath, obfuscationResult.getObfuscatedCode(), 'utf8');
};
+1
View File
@@ -9,6 +9,7 @@
},
"dependencies": {
"@nocobase/app": "1.7.0-alpha.13",
"@nocobase/license-kit": "^0.2.3",
"@types/fs-extra": "^11.0.1",
"@umijs/utils": "3.5.20",
"chalk": "^4.1.1",
+2
View File
@@ -35,6 +35,8 @@ module.exports = (cli) => {
require('./upgrade')(cli);
require('./postinstall')(cli);
require('./pkg')(cli);
require('./instance-id')(cli);
require('./view-license-key')(cli);
if (isPackageValid('@umijs/utils')) {
require('./create-plugin')(cli);
}
@@ -0,0 +1,46 @@
/**
* 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 chalk = require('chalk');
const { Command } = require('commander');
const { run, isDev } = require('../util');
const { getInstanceIdAsync } = require('@nocobase/license-kit');
const path = require('path');
const fs = require('fs');
/**
*
* @param {Command} cli
*/
module.exports = (cli) => {
cli
.command('generate-instance-id')
.description('Generate InstanceID')
.option('--force', 'Force generate InstanceID')
.action(async (options) => {
console.log('Generating InstanceID...');
const dir = path.resolve(process.cwd(), 'storage/.license');
const filePath = path.resolve(dir, 'instance-id');
if (fs.existsSync(filePath) && !options.force) {
console.log('InstanceID already exists at ' + filePath);
return;
} else {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
try {
const instanceId = await getInstanceIdAsync();
fs.writeFileSync(filePath, instanceId + '\n');
console.log(chalk.greenBright(`InstanceID saved to ${filePath}`));
} catch (e) {
console.log(e);
}
}
});
};
+6 -2
View File
@@ -15,6 +15,7 @@ const tar = require('tar');
const path = require('path');
const { createStoragePluginsSymlink } = require('@nocobase/utils/plugin-symlink');
const chalk = require('chalk');
const { getAccessKeyPair } = require('../util');
class Package {
data;
@@ -248,10 +249,13 @@ module.exports = (cli) => {
NOCOBASE_PKG_USERNAME,
NOCOBASE_PKG_PASSWORD,
} = process.env;
if (!(NOCOBASE_PKG_USERNAME && NOCOBASE_PKG_PASSWORD)) {
const { accessKeyId, accessKeySecret } = getAccessKeyPair();
if (!(NOCOBASE_PKG_USERNAME && NOCOBASE_PKG_PASSWORD) && !(accessKeyId && accessKeySecret)) {
return;
}
const credentials = { username: NOCOBASE_PKG_USERNAME, password: NOCOBASE_PKG_PASSWORD };
const credentials = accessKeyId
? { username: accessKeyId, password: accessKeySecret }
: { username: NOCOBASE_PKG_USERNAME, password: NOCOBASE_PKG_PASSWORD };
const pm = new PackageManager({ baseURL: NOCOBASE_PKG_URL });
await pm.login(credentials);
const file = path.resolve(__dirname, '../../package.json');
@@ -0,0 +1,44 @@
/**
* 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 chalk = require('chalk');
const { Command } = require('commander');
const { keyDecrypt } = require('@nocobase/license-kit');
const path = require('path');
const fs = require('fs');
/**
*
* @param {Command} cli
*/
module.exports = (cli) => {
cli
.command('view-license-key')
.description('View License Key')
.action(async (options) => {
const dir = path.resolve(process.cwd(), 'storage/.license');
const filePath = path.resolve(dir, 'license-key');
if (!fs.existsSync(filePath)) {
console.log('License key not found at ' + filePath);
return;
}
const key = fs.readFileSync(filePath, 'utf-8');
let keyDataStr;
try {
keyDataStr = keyDecrypt(key);
} catch (e) {
console.log('License key decrypt failed', e);
return;
}
const keyData = JSON.parse(keyDataStr);
const { accessKeyId, accessKeySecret } = keyData;
console.log(chalk.greenBright(`Access Key ID: ${accessKeyId}`));
console.log(chalk.greenBright(`Access Key Secret: ${accessKeySecret}`));
});
};
+24 -4
View File
@@ -18,6 +18,7 @@ const dotenv = require('dotenv');
const fs = require('fs-extra');
const os = require('os');
const moment = require('moment-timezone');
const { keyDecrypt } = require('@nocobase/license-kit');
exports.isPackageValid = (pkg) => {
try {
@@ -165,10 +166,11 @@ exports.promptForTs = () => {
};
exports.downloadPro = async () => {
const { NOCOBASE_PKG_USERNAME, NOCOBASE_PKG_PASSWORD } = process.env;
if (!(NOCOBASE_PKG_USERNAME && NOCOBASE_PKG_PASSWORD)) {
return;
}
// 此处不再判定,由pkgg命令处理
// const { NOCOBASE_PKG_USERNAME, NOCOBASE_PKG_PASSWORD } = process.env;
// if (!(NOCOBASE_PKG_USERNAME && NOCOBASE_PKG_PASSWORD)) {
// return;
// }
await exports.run('yarn', ['nocobase', 'pkg', 'download-pro']);
};
@@ -487,3 +489,21 @@ exports.generatePlugins = function () {
return;
}
};
exports.getAccessKeyPair = function () {
const keyFile = resolve(process.cwd(), 'storage/.license/license-key');
if (!fs.existsSync(keyFile)) {
console.log(chalk.yellow('license-key file not found', keyFile));
return {};
}
try {
const str = fs.readFileSync(keyFile, 'utf-8');
const keyDataStr = keyDecrypt(str);
const keyData = JSON.parse(keyDataStr);
const { accessKeyId, accessKeySecret } = keyData;
return { accessKeyId, accessKeySecret };
} catch (error) {
console.log(chalk.yellow('Key parse failed, please check your key'));
return {};
}
};
@@ -1102,7 +1102,6 @@
"Italic": "斜体",
"Response record":"响应结果记录",
"Colon":"冒号",
"After successful submission, the selected data blocks will be automatically refreshed.": "提交成功后,会自动刷新这里选中的数据区块。",
"No pages yet, please configure first": "暂无页面,请先配置",
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "点击右上角的“界面配置”图标,进入界面配置模式",
"Specifies a Permissions Policy for the <iframe>. The policy defines what features are available to the <iframe> (for example, access to the microphone, camera, battery, web-share, etc.) based on the origin of the request.": "用于为 <iframe> 指定其权限策略。该策略根据请求的来源规定 <iframe> 可以使用哪些特性(例如,访问麦克风、摄像头、电池、web 共享等)。",
@@ -270,7 +270,6 @@ const LocalPlugins = () => {
// if (isRefresh) refresh();
}}
/>
<div style={{ width: '100%' }}>
<div
style={{ marginBottom: theme.marginLG }}
@@ -279,7 +279,12 @@ const InternalAction: React.FC<InternalActionProps> = observer(function Com(prop
const { modal } = App.useApp();
const form = useForm();
const aclCtx = useACLActionParamsContext();
const { run, element, disabled: disableAction, loading: loadingOfUseAction } = useAction?.(actionCallback) || ({} as any);
const {
run,
element,
disabled: disableAction,
loading: loadingOfUseAction,
} = useAction?.(actionCallback) || ({} as any);
const disabled = form.disabled || field.disabled || field.data?.disabled || propsDisabled || disableAction;
const buttonStyle = useMemo(() => {
return {
@@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useTranslation } from "react-i18next";
import { useTranslation } from 'react-i18next';
/**
* 变量:`系统设置`
@@ -28,7 +28,7 @@ export const useSystemSettingsVariable = () => {
label: t('System title'),
isLeaf: true,
},
]
],
};
return {
-1
View File
@@ -243,7 +243,6 @@ export class Auth {
return response;
}
async lostPassword(values: any): Promise<AxiosResponse<any>> {
// 获取当前 URL 的查询参数
const searchParams = new URLSearchParams(window.location.search);
@@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { parsedValue } from "../parsedValue";
import { parsedValue } from '../parsedValue';
describe('parsedValue', () => {
it('should correctly parse simple templates', () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { parse } from "./json-templates";
import { parse } from './json-templates';
function appendArrayColumn(scope, key) {
const paths = key.split('.');
@@ -18,23 +18,23 @@ import { useSearchParams } from 'react-router-dom';
vi.mock('react-router-dom', () => ({
useSearchParams: vi.fn(() => [
{
get: (key: string) => key === 'name' ? 'basic' : null
}
get: (key: string) => (key === 'name' ? 'basic' : null),
},
]),
Navigate: vi.fn(() => <div data-testid="navigate">Navigate to not-found</div>)
Navigate: vi.fn(() => <div data-testid="navigate">Navigate to not-found</div>),
}));
vi.mock('../authenticator', () => ({
useAuthenticator: vi.fn()
useAuthenticator: vi.fn(),
}));
vi.mock('@nocobase/client', () => ({
SchemaComponent: vi.fn(({ schema, scope }) => <div data-testid="schema-component">Schema Component</div>),
useAPIClient: vi.fn()
useAPIClient: vi.fn(),
}));
vi.mock('../locale', () => ({
useAuthTranslation: vi.fn(() => ({ t: (key: string) => key }))
useAuthTranslation: vi.fn(() => ({ t: (key: string) => key })),
}));
describe('ForgotPasswordPage', () => {
@@ -46,8 +46,8 @@ describe('ForgotPasswordPage', () => {
// 模拟认证器允许重置密码
vi.mocked(useAuthenticator).mockReturnValue({
options: {
enableResetPassword: true
}
enableResetPassword: true,
},
} as any);
render(<ForgotPasswordPage />);
@@ -58,8 +58,8 @@ describe('ForgotPasswordPage', () => {
// 模拟认证器不允许重置密码
vi.mocked(useAuthenticator).mockReturnValue({
options: {
enableResetPassword: false
}
enableResetPassword: false,
},
} as any);
render(<ForgotPasswordPage />);
@@ -79,15 +79,15 @@ describe('ForgotPasswordPage', () => {
const mockName = 'custom-auth';
vi.mocked(useSearchParams).mockReturnValue([
{
get: (key: string) => key === 'name' ? mockName : null
}
get: (key: string) => (key === 'name' ? mockName : null),
},
] as any);
// 模拟认证器允许重置密码
vi.mocked(useAuthenticator).mockReturnValue({
options: {
enableResetPassword: true
}
enableResetPassword: true,
},
} as any);
render(<ForgotPasswordPage />);
@@ -23,28 +23,28 @@ vi.mock('react-router-dom', () => ({
if (key === 'name') return 'basic';
if (key === 'resetToken') return 'valid-token';
return null;
}
}
},
},
]),
Navigate: vi.fn(() => <div data-testid="navigate">Navigate to not-found</div>)
Navigate: vi.fn(() => <div data-testid="navigate">Navigate to not-found</div>),
}));
vi.mock('../authenticator', () => ({
useAuthenticator: vi.fn()
useAuthenticator: vi.fn(),
}));
vi.mock('@nocobase/client', () => ({
SchemaComponent: vi.fn(({ schema, scope }) => <div data-testid="schema-component">Schema Component</div>),
useAPIClient: vi.fn(() => ({
auth: {
checkResetToken: vi.fn().mockResolvedValue(true)
}
checkResetToken: vi.fn().mockResolvedValue(true),
},
})),
useNavigateNoUpdate: vi.fn(() => vi.fn())
useNavigateNoUpdate: vi.fn(() => vi.fn()),
}));
vi.mock('../locale', () => ({
useAuthTranslation: vi.fn(() => ({ t: (key: string) => key }))
useAuthTranslation: vi.fn(() => ({ t: (key: string) => key })),
}));
vi.mock('antd', () => ({
@@ -56,8 +56,8 @@ vi.mock('antd', () => ({
</div>
)),
message: {
success: vi.fn()
}
success: vi.fn(),
},
}));
describe('ResetPasswordPage', () => {
@@ -69,8 +69,8 @@ describe('ResetPasswordPage', () => {
// 模拟认证器允许重置密码
vi.mocked(useAuthenticator).mockReturnValue({
options: {
enableResetPassword: true
}
enableResetPassword: true,
},
} as any);
// 模拟有效的重置令牌
@@ -80,8 +80,8 @@ describe('ResetPasswordPage', () => {
if (key === 'name') return 'basic';
if (key === 'resetToken') return 'valid-token';
return null;
}
}
},
},
] as any);
render(<ResetPasswordPage />);
@@ -96,8 +96,8 @@ describe('ResetPasswordPage', () => {
// 模拟认证器不允许重置密码
vi.mocked(useAuthenticator).mockReturnValue({
options: {
enableResetPassword: false
}
enableResetPassword: false,
},
} as any);
render(<ResetPasswordPage />);
@@ -118,15 +118,15 @@ describe('ResetPasswordPage', () => {
// 模拟认证器允许重置密码
vi.mocked(useAuthenticator).mockReturnValue({
options: {
enableResetPassword: true
}
enableResetPassword: true,
},
} as any);
// 模拟过期或无效的令牌
vi.mocked(useAPIClient).mockReturnValue({
auth: {
checkResetToken: vi.fn().mockRejectedValue(new Error('Token expired'))
}
checkResetToken: vi.fn().mockRejectedValue(new Error('Token expired')),
},
} as any);
render(<ResetPasswordPage />);
@@ -146,15 +146,15 @@ describe('ResetPasswordPage', () => {
if (key === 'name') return mockName;
if (key === 'resetToken') return 'valid-token';
return null;
}
}
},
},
] as any);
// 模拟认证器允许重置密码
vi.mocked(useAuthenticator).mockReturnValue({
options: {
enableResetPassword: true
}
enableResetPassword: true,
},
} as any);
render(<ResetPasswordPage />);
@@ -7,7 +7,15 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { SchemaComponent, useCollectionManager, useCurrentUserVariable, useDatetimeVariable, useGlobalVariable, useRecord, useSystemSettingsVariable } from '@nocobase/client';
import {
SchemaComponent,
useCollectionManager,
useCurrentUserVariable,
useDatetimeVariable,
useGlobalVariable,
useRecord,
useSystemSettingsVariable,
} from '@nocobase/client';
import React, { useEffect, useMemo } from 'react';
import { lang, useAuthTranslation } from '../locale';
import { FormTab, ArrayTable } from '@formily/antd-v5';
@@ -153,14 +161,20 @@ const useVariableOptionsOfForgetPassword = () => {
const { currentUserSettings } = useCurrentUserVariable({ maxDepth: 1 });
const { systemSettings } = useSystemSettingsVariable();
return [environmentVariables, currentUserSettings, systemSettings, {
value: '$resetLink',
label: t('Reset password link'),
}, {
return [
environmentVariables,
currentUserSettings,
systemSettings,
{
value: '$resetLink',
label: t('Reset password link'),
},
{
value: '$resetLinkExpiration',
label: t('Reset link expiration (minutes)'),
}].filter(Boolean);
}
},
].filter(Boolean);
};
export const Options = () => {
const { t } = useAuthTranslation();
@@ -229,7 +243,11 @@ export const Options = () => {
divider1: {
type: 'void',
'x-component': () => {
return <Divider orientation="left" orientationMargin="0">{t('1. Select notification channel')}</Divider>;
return (
<Divider orientation="left" orientationMargin="0">
{t('1. Select notification channel')}
</Divider>
);
},
'x-reactions': [
{
@@ -276,12 +294,17 @@ export const Options = () => {
},
},
],
description: '{{t("The notification channel used to send the reset password email, only support email channel")}}',
description:
'{{t("The notification channel used to send the reset password email, only support email channel")}}',
},
divider2: {
type: 'void',
'x-component': () => {
return <Divider orientation="left" orientationMargin="0">{t('2. Configure reset email')}</Divider>;
return (
<Divider orientation="left" orientationMargin="0">
{t('2. Configure reset email')}
</Divider>
);
},
'x-reactions': [
{
@@ -399,7 +422,7 @@ export const Options = () => {
suffix: t('Minutes'),
style: {
width: '100%',
}
},
},
default: 120,
required: true,
@@ -116,7 +116,7 @@ const getPasswordForm = ({ showForgotPassword }: { showForgotPassword?: boolean
'x-visible': showForgotPassword,
},
},
}
},
},
});
export const SignInForm = (props: { authenticator: Authenticator }) => {
@@ -131,5 +131,10 @@ export const SignInForm = (props: { authenticator: Authenticator }) => {
const useBasicSignIn = () => {
return useSignIn(name);
};
return <SchemaComponent schema={getPasswordForm({ showForgotPassword: !!options?.enableResetPassword })} scope={{ useBasicSignIn, allowSignUp, signUpLink, t, authenticator }} />;
return (
<SchemaComponent
schema={getPasswordForm({ showForgotPassword: !!options?.enableResetPassword })}
scope={{ useBasicSignIn, allowSignUp, signUpLink, t, authenticator }}
/>
);
};
@@ -53,7 +53,7 @@ const getForgotPasswordForm = (): ISchema => ({
} finally {
setLoading(false);
}
message.success(t("Reset email sent successfully"));
message.success(t('Reset email sent successfully'));
form.reset();
},
loading,
@@ -80,7 +80,6 @@ export const ForgotPasswordPage = () => {
const name = searchParams.get('name');
const authenticator = useAuthenticator(name);
if (!authenticator?.options?.enableResetPassword) {
return <Navigate to="/not-found" replace={true} />;
}
@@ -62,7 +62,7 @@ const getResetPasswordForm = (): ISchema => ({
} finally {
setLoading(false);
}
message.success(t("Password reset successful"));
message.success(t('Password reset successful'));
setTimeout(() => {
window.location.href = '/signin';
}, 1000);
@@ -96,11 +96,14 @@ export const ResetPasswordPage = () => {
const authenticator = useAuthenticator(name);
useEffect(() => {
api.auth.checkResetToken({ resetToken }).then(() => {
setExpired(false);
}).catch((error) => {
setExpired(true);
});
api.auth
.checkResetToken({ resetToken })
.then(() => {
setExpired(false);
})
.catch((error) => {
setExpired(true);
});
}, []);
if (!authenticator?.options?.enableResetPassword) {
@@ -108,11 +111,17 @@ export const ResetPasswordPage = () => {
}
if (!resetToken || expired) {
return <Result
status="403"
title={t('Reset link has expired')}
extra={<Button type="primary" onClick={() => navigate('/signin')}>{t('Go to login')}</Button>}
/>;
return (
<Result
status="403"
title={t('Reset link has expired')}
extra={
<Button type="primary" onClick={() => navigate('/signin')}>
{t('Go to login')}
</Button>
}
/>
);
}
return <SchemaComponent schema={getResetPasswordForm()} scope={{ t }} />;
@@ -70,7 +70,7 @@ describe('auth:lostPassword', () => {
}
return null;
}),
}
},
};
// Add mock email channel
@@ -307,7 +307,9 @@ describe('auth:lostPassword', () => {
channelName: 'email',
message: expect.objectContaining({
to: ['test@example.com'],
html: expect.stringContaining(`${baseURL}/reset-password?resetToken=mock-reset-token&name=${authenticatorName}`),
html: expect.stringContaining(
`${baseURL}/reset-password?resetToken=mock-reset-token&name=${authenticatorName}`,
),
}),
}),
);
@@ -80,11 +80,14 @@ describe('auth:resetPassword & auth:checkResetToken', () => {
});
// Create a valid reset token
validToken = await app.authManager.jwt.sign({
resetPasswordUserId: testUser.id,
}, {
expiresIn: '1h',
});
validToken = await app.authManager.jwt.sign(
{
resetPasswordUserId: testUser.id,
},
{
expiresIn: '1h',
},
);
// Create an expired token for testing
expiredToken = 'expired.token.value';
@@ -116,24 +119,18 @@ describe('auth:resetPassword & auth:checkResetToken', () => {
// Tests for auth:checkResetToken
describe('auth:checkResetToken', () => {
it('should return true when token is valid', async () => {
const res = await agent
.post('/auth:checkResetToken')
.set({ 'X-Authenticator': 'basic' })
.send({
resetToken: validToken,
});
const res = await agent.post('/auth:checkResetToken').set({ 'X-Authenticator': 'basic' }).send({
resetToken: validToken,
});
expect(res.statusCode).toBe(200);
expect(res.body.data).toBe(true);
});
it('should return an error when token is expired', async () => {
const res = await agent
.post('/auth:checkResetToken')
.set({ 'X-Authenticator': 'basic' })
.send({
resetToken: expiredToken,
});
const res = await agent.post('/auth:checkResetToken').set({ 'X-Authenticator': 'basic' }).send({
resetToken: expiredToken,
});
expect(res.statusCode).toBe(401);
expect(res.error.text).toContain('Token expired');
@@ -142,12 +139,9 @@ describe('auth:resetPassword & auth:checkResetToken', () => {
it('should return an error when token is invalid', async () => {
app.authManager.jwt.decode = vi.fn().mockRejectedValue(new Error('Invalid token'));
const res = await agent
.post('/auth:checkResetToken')
.set({ 'X-Authenticator': 'basic' })
.send({
resetToken: 'invalid.token',
});
const res = await agent.post('/auth:checkResetToken').set({ 'X-Authenticator': 'basic' }).send({
resetToken: 'invalid.token',
});
expect(res.statusCode).toBe(401);
expect(res.error.text).toContain('Token expired');
@@ -157,13 +151,10 @@ describe('auth:resetPassword & auth:checkResetToken', () => {
// Tests for auth:resetPassword
describe('auth:resetPassword', () => {
it('should successfully reset password with valid token', async () => {
const res = await agent
.post('/auth:resetPassword')
.set({ 'X-Authenticator': 'basic' })
.send({
resetToken: validToken,
password: 'newpassword123',
});
const res = await agent.post('/auth:resetPassword').set({ 'X-Authenticator': 'basic' }).send({
resetToken: validToken,
password: 'newpassword123',
});
expect(res.statusCode).toBe(204);
@@ -171,37 +162,28 @@ describe('auth:resetPassword & auth:checkResetToken', () => {
expect(app.authManager.jwt.block).toHaveBeenCalledWith(validToken);
// Verify user password was changed by trying to sign in with the new password
const signInRes = await agent
.post('/auth:signIn')
.set({ 'X-Authenticator': 'basic' })
.send({
account: 'test@example.com',
password: 'newpassword123',
});
const signInRes = await agent.post('/auth:signIn').set({ 'X-Authenticator': 'basic' }).send({
account: 'test@example.com',
password: 'newpassword123',
});
expect(signInRes.statusCode).toBe(200);
});
it('should return an error when resetToken is not provided', async () => {
const res = await agent
.post('/auth:resetPassword')
.set({ 'X-Authenticator': 'basic' })
.send({
password: 'newpassword123',
});
const res = await agent.post('/auth:resetPassword').set({ 'X-Authenticator': 'basic' }).send({
password: 'newpassword123',
});
expect(res.statusCode).toBe(401);
expect(res.error.text).toContain('Token expired');
});
it('should return an error when resetToken is expired', async () => {
const res = await agent
.post('/auth:resetPassword')
.set({ 'X-Authenticator': 'basic' })
.send({
resetToken: expiredToken,
password: 'newpassword123',
});
const res = await agent.post('/auth:resetPassword').set({ 'X-Authenticator': 'basic' }).send({
resetToken: expiredToken,
password: 'newpassword123',
});
expect(res.statusCode).toBe(401);
expect(res.error.text).toContain('Token expired');
@@ -219,13 +201,10 @@ describe('auth:resetPassword & auth:checkResetToken', () => {
app.authManager.jwt.blacklist.has = vi.fn().mockResolvedValue(false);
const res = await agent
.post('/auth:resetPassword')
.set({ 'X-Authenticator': 'basic' })
.send({
resetToken: nonExistentUserToken,
password: 'newpassword123',
});
const res = await agent.post('/auth:resetPassword').set({ 'X-Authenticator': 'basic' }).send({
resetToken: nonExistentUserToken,
password: 'newpassword123',
});
expect(res.statusCode).toBe(404);
expect(res.error.text).toContain('User not found');
@@ -39,8 +39,8 @@ export class BasicAuth extends BaseAuth {
const filter = email
? { email }
: {
$or: [{ username: account }, { email: account }],
};
$or: [{ username: account }, { email: account }],
};
const user = await this.userRepository.findOne({
filter,
});
@@ -182,23 +182,34 @@ export class BasicAuth extends BaseAuth {
}
// 通过用户认证的接口获取邮件渠道、主题、内容等
const { notificationChannel, emailContentType, emailContentHTML, emailContentText, emailSubject, enableResetPassword, resetTokenExpiresIn } = this.getEmailConfig();
const {
notificationChannel,
emailContentType,
emailContentHTML,
emailContentText,
emailSubject,
enableResetPassword,
resetTokenExpiresIn,
} = this.getEmailConfig();
if (!enableResetPassword) {
ctx.throw(403, ctx.t('Not allowed to reset password', { ns: namespace }));
}
// 生成重置密码的 token
const resetToken = await ctx.app.authManager.jwt.sign({
resetPasswordUserId: user.id,
}, {
expiresIn: resetTokenExpiresIn * 60, // 配置的过期时间,单位分钟,需要转成秒
});
const resetToken = await ctx.app.authManager.jwt.sign(
{
resetPasswordUserId: user.id,
},
{
expiresIn: resetTokenExpiresIn * 60, // 配置的过期时间,单位分钟,需要转成秒
},
);
// 构建重置密码链接
const resetLink = `${baseURL}/reset-password?resetToken=${resetToken}&name=${authenticatorName}`;
const systemSettings = await ctx.db.getRepository('systemSettings')?.findOne() || {};
const systemSettings = (await ctx.db.getRepository('systemSettings')?.findOne()) || {};
// 通过通知管理插件发送邮件
const notificationManager = ctx.app.getPlugin('notification-manager');
@@ -232,7 +243,7 @@ export class BasicAuth extends BaseAuth {
subject: parsedSubject,
contentType: emailContentType,
...content,
}
},
});
ctx.logger.info(`Password reset email sent to ${email}`);
@@ -240,23 +251,29 @@ export class BasicAuth extends BaseAuth {
ctx.logger.error(`Failed to send reset password email: ${error.message}`, {
error,
email,
notificationChannel
notificationChannel,
});
ctx.throw(500, ctx.t('Failed to send email. Error: {{error}}', {
ns: namespace,
error: error.message
}));
ctx.throw(
500,
ctx.t('Failed to send email. Error: {{error}}', {
ns: namespace,
error: error.message,
}),
);
}
} catch (error) {
ctx.logger.error(`Error parsing email template variables: ${error.message}`, {
error,
emailSubject,
emailContentType
emailContentType,
});
ctx.throw(500, ctx.t('Error parsing email template. Error: {{error}}', {
ns: namespace,
error: error.message
}));
ctx.throw(
500,
ctx.t('Error parsing email template. Error: {{error}}', {
ns: namespace,
error: error.message,
}),
);
}
} else {
ctx.throw(400, ctx.t('Email channel not found', { ns: namespace }));
@@ -119,7 +119,9 @@ export class PluginAuthServer extends Plugin {
// Set up ACL
['signIn', 'signUp'].forEach((action) => this.app.acl.allow('auth', action));
['check', 'signOut', 'changePassword'].forEach((action) => this.app.acl.allow('auth', action, 'loggedIn'));
['lostPassword', 'resetPassword', 'checkResetToken'].forEach((action) => this.app.acl.allow('auth', action, 'public'));
['lostPassword', 'resetPassword', 'checkResetToken'].forEach((action) =>
this.app.acl.allow('auth', action, 'public'),
);
this.app.acl.allow('authenticators', 'publicList');
this.app.acl.registerSnippet({
name: `pm.${this.name}.authenticators`,
@@ -322,7 +324,7 @@ export class PluginAuthServer extends Plugin {
});
}
async remove() { }
async remove() {}
}
export default PluginAuthServer;