mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-28 17:43:07 +08:00
feat(client-v2): add standalone settings SPA (#10187)
* feat(client-v2): add standalone settings SPA * style(client-v2): use primary header color * fix(plugin-ui-layout): hide mobile menu * style(client-v2): regroup settings action * fix(client-v2): match settings header color * fix(client-v2): group portal manager * fix(client-v2): group topbar managers * feat(multi-portal): add portal routes * fix(app): normalize settings dev root * feat(auth): add settings SPA sign-in * docs(auth): add settings sign-in plan * fix(client-v2): align settings embed layout * fix(client-v2): normalize settings app URLs * feat(multi-portal): unify client v2 portals * fix(ci): repair PR validation failures * fix(client-v2): flatten scoped settings URLs * fix(client-v2): redirect roots to settings * fix(cli): initialize AI portal env defaults * fix(cli): remove portal setup from init * fix(cli): skip unsupported portal registry sync * fix(cli): narrow init env config types * refactor(multi-portal): use fixed portal uids * fix(cli): skip portal registry sync during install * fix(plugin-multi-portal): allow reserved slugs * fix(client-v2): normalize settings app roots --------- Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
@@ -30,6 +30,7 @@ cache/diskstore-*
|
||||
packages/core/client/docs/contributing.md
|
||||
packages/core/app/client/src/.plugins
|
||||
packages/core/app/client-v2/src/.plugins
|
||||
packages/core/app/client-settings/src/.plugins
|
||||
tsconfig.paths.json
|
||||
/playwright
|
||||
.swc
|
||||
|
||||
@@ -64,9 +64,9 @@ case "${NOCOBASE_EXTRACT_CLIENT_ASSETS:-false}" in
|
||||
export NB_CLI_LOG_DISABLED=1
|
||||
APP_PUBLIC_PATH_VALUE="${APP_PUBLIC_PATH:-/}"
|
||||
PROXY_CDN_BASE_URL=''
|
||||
if [ -n "${CDN_BASE_URL:-}" ]; then
|
||||
PROXY_CDN_BASE_URL="${CDN_BASE_URL%/}/"
|
||||
if [ -n "${EXPLICIT_CDN_BASE_URL}" ] && [ "${CDN_VERSION:-}" = "auto" ]; then
|
||||
if [ -n "${EXPLICIT_CDN_BASE_URL}" ]; then
|
||||
PROXY_CDN_BASE_URL="${EXPLICIT_CDN_BASE_URL%/}/"
|
||||
if [ "${CDN_VERSION:-}" = "auto" ]; then
|
||||
PROXY_CDN_BASE_URL="${PROXY_CDN_BASE_URL}${ACTIVE_VERSION}/"
|
||||
fi
|
||||
fi
|
||||
|
||||
+1
-3
@@ -2,9 +2,7 @@
|
||||
"version": "2.2.0-alpha.11",
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"npmClientArgs": [
|
||||
"--ignore-engines"
|
||||
],
|
||||
"npmClientArgs": ["--ignore-engines"],
|
||||
"command": {
|
||||
"version": {
|
||||
"forcePublish": true,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import { createSettingsDevProxyOptions, isSettingsDevPath, rewriteSettingsDevProxyPath } from '../settingsDevProxy';
|
||||
|
||||
describe('settings dev proxy', () => {
|
||||
it.each([
|
||||
['/settings', true],
|
||||
['/settings/signin', true],
|
||||
['/settings/signup', true],
|
||||
['/settings/forgot-password', true],
|
||||
['/settings/reset-password?resetToken=test-token', true],
|
||||
['/settings/2fa?redirect=%2Fsettings%2Fworkflow', true],
|
||||
['/settings/workflow/workflows/1?tab=nodes', true],
|
||||
['/settings/apps/demo/signin', true],
|
||||
['/settings/apps/demo', true],
|
||||
['/settings/apps/demo/workflow/workflows/1', true],
|
||||
['/settings/_app/demo/reset-password?resetToken=test-token', true],
|
||||
['/settings/_app/demo/ai/knowledge-base/detail/k1', true],
|
||||
['/nocobase/settings/assets/index.js', false],
|
||||
['/admin/settings', false],
|
||||
['/v/admin/settings', false],
|
||||
['/apps/demo/settings', false],
|
||||
['/_app/demo/settings', false],
|
||||
['/apps/demo/admin/settings', false],
|
||||
])('matches only standalone Settings paths: %s', (pathname, expected) => {
|
||||
expect(isSettingsDevPath(pathname, '/')).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/nocobase/settings', true],
|
||||
['/nocobase/settings/signin', true],
|
||||
['/nocobase/settings/2fa?redirect=%2Fnocobase%2Fsettings', true],
|
||||
['/nocobase/settings/workflow/workflows/1', true],
|
||||
['/nocobase/settings/apps/demo/forgot-password', true],
|
||||
['/nocobase/settings/apps/demo', true],
|
||||
['/nocobase/settings/_app/demo/reset-password?resetToken=test-token', true],
|
||||
['/nocobase/settings/_app/demo/ai/knowledge-base/detail/k1', true],
|
||||
['/settings', false],
|
||||
['/nocobase/admin/settings', false],
|
||||
['/nocobase/apps/demo/settings', false],
|
||||
])('honors APP_PUBLIC_PATH: %s', (pathname, expected) => {
|
||||
expect(isSettingsDevPath(pathname, '/nocobase/')).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/settings', '/', '/settings/'],
|
||||
['/settings?from=admin', '/', '/settings/?from=admin'],
|
||||
['/settings/apps/demo', '/', '/settings/apps/demo'],
|
||||
['/settings/_app/demo?from=admin', '/', '/settings/_app/demo?from=admin'],
|
||||
['/nocobase/settings', '/nocobase/', '/nocobase/settings/'],
|
||||
['/nocobase/settings/apps/demo?from=admin', '/nocobase/', '/nocobase/settings/apps/demo?from=admin'],
|
||||
['/nocobase/settings/_app/demo#portal', '/nocobase/', '/nocobase/settings/_app/demo#portal'],
|
||||
])('normalizes a Settings root to the dev-server base: %s', (pathname, publicPath, expected) => {
|
||||
expect(rewriteSettingsDevProxyPath(pathname, publicPath)).toBe(expected);
|
||||
});
|
||||
|
||||
it('preserves application-scoped documents for the Settings dev server', () => {
|
||||
expect(rewriteSettingsDevProxyPath('/settings/apps/demo/workflow/workflows/1?tab=nodes', '/')).toBe(
|
||||
'/settings/apps/demo/workflow/workflows/1?tab=nodes',
|
||||
);
|
||||
expect(rewriteSettingsDevProxyPath('/nocobase/settings/apps/demo/a#hash', '/nocobase/')).toBe(
|
||||
'/nocobase/settings/apps/demo/a#hash',
|
||||
);
|
||||
expect(rewriteSettingsDevProxyPath('/settings/assets/index.js', '/')).toBe('/settings/assets/index.js');
|
||||
expect(rewriteSettingsDevProxyPath('/settings/__rspack_hmr', '/')).toBe('/settings/__rspack_hmr');
|
||||
});
|
||||
|
||||
it('creates a websocket-capable proxy for the Settings port', () => {
|
||||
const options = createSettingsDevProxyOptions('/nocobase/', 13004);
|
||||
|
||||
expect(options).toMatchObject({
|
||||
target: 'http://127.0.0.1:13004',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
xfwd: true,
|
||||
});
|
||||
expect(options.context?.('/nocobase/settings/__rspack_hmr')).toBe(true);
|
||||
expect(options.context?.('/nocobase/settings/signin')).toBe(true);
|
||||
expect(options.context?.('/nocobase/settings/apps/demo/workflow')).toBe(true);
|
||||
expect(options.context?.('/nocobase/settings/apps/demo/2fa')).toBe(true);
|
||||
expect(options.context?.('/nocobase/settings/_app/demo/ai')).toBe(true);
|
||||
expect(options.context?.('/nocobase/apps/demo/settings/workflow')).toBe(false);
|
||||
expect(options.context?.('/nocobase/admin/settings')).toBe(false);
|
||||
expect(options.pathRewrite?.('/nocobase/settings/apps/demo/workflow')).toBe(
|
||||
'/nocobase/settings/apps/demo/workflow',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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 { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { generateSettingsPluginImports } from '../client-settings/generatePluginImports';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
function createTemporaryDirectory() {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'nocobase-settings-plugin-imports-'));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function createV2Plugin(pluginRoot: string) {
|
||||
const pluginDirectory = path.join(pluginRoot, '@example', 'plugin-demo');
|
||||
mkdirSync(pluginDirectory, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(pluginDirectory, 'package.json'),
|
||||
JSON.stringify({ name: '@example/plugin-demo', version: '1.0.0' }),
|
||||
);
|
||||
writeFileSync(path.join(pluginDirectory, 'client-v2.js'), 'module.exports = {};');
|
||||
mkdirSync(path.join(pluginDirectory, 'src', 'client-v2'), { recursive: true });
|
||||
writeFileSync(path.join(pluginDirectory, 'src', 'client-v2', 'index.ts'), 'export default class DemoPlugin {}');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('Settings plugin imports', () => {
|
||||
it('generates the existing V2 plugin lane into a Settings-owned directory', () => {
|
||||
const root = createTemporaryDirectory();
|
||||
const pluginRoot = path.join(root, 'plugins');
|
||||
const settingsOutput = path.join(root, 'client-settings', 'src', '.plugins');
|
||||
const clientV2Output = path.join(root, 'client-v2', 'src', '.plugins');
|
||||
createV2Plugin(pluginRoot);
|
||||
mkdirSync(clientV2Output, { recursive: true });
|
||||
writeFileSync(path.join(clientV2Output, 'index.ts'), 'export const clientV2DevManifest = true;');
|
||||
|
||||
const localPluginsOnly = process.env.NOCOBASE_DEV_LOCAL_PLUGINS_ONLY;
|
||||
process.env.NOCOBASE_DEV_LOCAL_PLUGINS_ONLY = 'true';
|
||||
try {
|
||||
generateSettingsPluginImports(settingsOutput, [pluginRoot]);
|
||||
} finally {
|
||||
if (localPluginsOnly === undefined) {
|
||||
delete process.env.NOCOBASE_DEV_LOCAL_PLUGINS_ONLY;
|
||||
} else {
|
||||
process.env.NOCOBASE_DEV_LOCAL_PLUGINS_ONLY = localPluginsOnly;
|
||||
}
|
||||
}
|
||||
|
||||
expect(JSON.parse(readFileSync(path.join(settingsOutput, 'packageMap.json'), 'utf8'))).toEqual({
|
||||
'@example/plugin-demo': 'example_plugin_demo.ts',
|
||||
});
|
||||
expect(readFileSync(path.join(clientV2Output, 'index.ts'), 'utf8')).toBe(
|
||||
'export const clientV2DevManifest = true;',
|
||||
);
|
||||
expect(existsSync(path.join(settingsOutput, 'packages', 'example_plugin_demo.ts'))).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a production Settings manifest isolated from the Client V2 development manifest', () => {
|
||||
const root = createTemporaryDirectory();
|
||||
const settingsOutput = path.join(root, 'client-settings', 'src', '.plugins');
|
||||
const clientV2Output = path.join(root, 'client-v2', 'src', '.plugins');
|
||||
mkdirSync(clientV2Output, { recursive: true });
|
||||
writeFileSync(path.join(clientV2Output, 'index.ts'), 'export const clientV2DevManifest = true;');
|
||||
|
||||
const nodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
try {
|
||||
generateSettingsPluginImports(settingsOutput, []);
|
||||
} finally {
|
||||
if (nodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV;
|
||||
} else {
|
||||
process.env.NODE_ENV = nodeEnv;
|
||||
}
|
||||
}
|
||||
|
||||
expect(readFileSync(path.join(settingsOutput, 'index.ts'), 'utf8')).toContain('return Promise.resolve(null)');
|
||||
expect(readFileSync(path.join(clientV2Output, 'index.ts'), 'utf8')).toBe(
|
||||
'export const clientV2DevManifest = true;',
|
||||
);
|
||||
});
|
||||
|
||||
it('wires the Settings config and entry to the Settings-owned generated directory', () => {
|
||||
const configSource = readFileSync(path.resolve(__dirname, '../client-settings/rsbuild.config.ts'), 'utf8');
|
||||
const entrySource = readFileSync(path.resolve(__dirname, '../client-settings/src/main.tsx'), 'utf8');
|
||||
|
||||
expect(configSource).not.toContain('generateV2Plugins');
|
||||
expect(configSource).toContain("generateSettingsPluginImports(path.resolve(__dirname, 'src/.plugins'))");
|
||||
expect(entrySource).toContain("from './.plugins'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
# Client V2 Settings 独立登录页实施计划
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前独立 Settings SPA 在访问受保护的 `/settings/**` 页面且用户未登录时,会整页跳转到 Client V2 Admin 的登录页:
|
||||
|
||||
```text
|
||||
/v/signin?redirect=<原 Settings 地址>
|
||||
```
|
||||
|
||||
目标是让 Settings SPA 自己承载完整的 Client V2 登录流程:
|
||||
|
||||
```text
|
||||
/settings/signin?redirect=<原 Settings 地址>
|
||||
```
|
||||
|
||||
这不是复制一份简化登录页,而是在 Settings SPA 中复用 Client V2 已有的 `AuthProvider`、`AuthLayout`、`SignInPage`、认证器注册表和认证插件 lane,使账号密码、短信、注册、密码重置、外部认证及 2FA 的功能和原 Client V2 登录页保持一致。
|
||||
|
||||
普通 Client V2 Application 仍使用 `/v/signin`;Client V1 的登录页和 `/admin/settings/**` 也保持不变。
|
||||
|
||||
## 2. 成功标准
|
||||
|
||||
完成后必须同时满足:
|
||||
|
||||
1. 主应用 Settings 登录路由为 `/settings/signin`。
|
||||
2. 子应用 Settings 登录路由分别为:
|
||||
- `/settings/apps/:app/signin`
|
||||
- `/settings/_app/:app/signin`
|
||||
3. 配置 `APP_PUBLIC_PATH=/nocobase/` 时,以上地址统一位于 `/nocobase/` 下。
|
||||
4. `/v/signin`、`/v/apps/:app/signin` 及自定义 modern prefix 对应的登录地址完全不变。
|
||||
5. Settings 登录页使用原 Client V2 登录界面和认证器注册机制,不出现 Settings 顶栏、侧栏或 Admin Layout。
|
||||
6. 未登录、会话过期、401、退出登录和修改密码后重新登录均进入当前 Application 对应的登录页。
|
||||
7. 登录成功后准确回到原 Settings 深链,并保留 query 和 hash。
|
||||
8. 账号密码、短信、注册、忘记密码、重置密码、2FA、OIDC、SAML 和 CAS 均形成闭环。
|
||||
9. 非法、跨域或跨应用 `redirect` 不被接受,回退到当前应用作用域的 Settings 根页。
|
||||
10. Settings 登录文档仍由独立 Settings 构建产物提供,assets、CDN 和缓存策略不变。
|
||||
|
||||
## 3. 不在范围内
|
||||
|
||||
- 不改变 Client V1 登录页、`/admin/settings/**` 或 V1 Email OAuth callback。
|
||||
- 不把普通 Client V2 Admin 的登录页迁出 `/v`。
|
||||
- 不增加新的认证器协议、插件 lane、公开 Application option 或公开路由 API。
|
||||
- 不改变 token、session、认证 API payload、数据库结构或认证器配置结构。
|
||||
- App SSO 的 `/app-sso` 启动页、IDP OAuth interaction 页面继续属于原 Portal;只验证它们不会把 Settings 登录链路错误带到其他 SPA。
|
||||
- 不为 Settings SPA 单独复制认证组件或认证器实现。
|
||||
|
||||
## 4. 当前实现与缺口
|
||||
|
||||
### 4.1 Settings 仍指向 `/v/signin`
|
||||
|
||||
`packages/core/client-v2/src/authRedirect.ts` 中的 `getV2SigninPath()` 会把独立 Settings Application 映射到 modern client prefix 下的登录页。Settings 的初始鉴权失败、运行时 401、退出和修改密码都会间接使用该逻辑。
|
||||
|
||||
现有测试也明确锁定了 `/v/signin`:
|
||||
|
||||
- `packages/core/client-v2/src/__tests__/authRedirect.test.ts`
|
||||
- `packages/core/client-v2/src/__tests__/settings-layout-root.test.tsx`
|
||||
- `packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/plugin.test.tsx`
|
||||
- `packages/plugins/@nocobase/plugin-users/src/client-v2/__tests__/plugin.test.ts`
|
||||
|
||||
### 4.2 Settings Router 当前丢弃认证路由
|
||||
|
||||
`SettingsRouterManager` 目前只接受:
|
||||
|
||||
- `settings.*`
|
||||
- `settingsDetails.*`
|
||||
- `not-found`
|
||||
|
||||
因此同一条 `pm:listEnabledV2` 插件 lane 虽然会加载 `plugin-auth` 和 2FA 插件,但下列路由会被过滤:
|
||||
|
||||
- `auth.signin` → `/signin`
|
||||
- `auth.signup` → `/signup`
|
||||
- `auth.forgotPassword` → `/forgot-password`
|
||||
- `auth.resetPassword` → `/reset-password`
|
||||
- `2fa.verify` → `/2fa`
|
||||
|
||||
### 4.3 登录组件包含根路径硬编码
|
||||
|
||||
以下 Client V2 组件直接使用 `/signin`、`/signup` 或 `/forgot-password`:
|
||||
|
||||
- `BasicSignInForm.tsx`
|
||||
- `BasicSignUpForm.tsx`
|
||||
- `ForgotPasswordPage.tsx`
|
||||
- `ResetPasswordPage.tsx`
|
||||
- `TwoFactorAuthLayout.tsx`
|
||||
|
||||
如果只注册 `/settings/signin` 而不处理这些链接,注册、忘记密码、重置密码和 2FA 异常回退会离开 Settings SPA。
|
||||
|
||||
### 4.4 SettingsShell 会包裹所有路由
|
||||
|
||||
`SettingsApplication` 当前把 `SettingsShell` 注册为全局 Provider。即使认证路由成功注册,登录页也会显示 Settings 精简顶栏。认证路由需要直接渲染原 `AuthLayout` 或 `TwoFactorAuthLayout`,不经过 Settings 顶栏和设置侧栏。
|
||||
|
||||
### 4.5 2FA 服务端返回固定 `/2fa`
|
||||
|
||||
2FA 服务端登录中间件返回:
|
||||
|
||||
```text
|
||||
/2fa?redirect=<target>
|
||||
```
|
||||
|
||||
普通 V1/V2 客户端依靠各自 basename 解析它。Settings SPA 需要在 Client V2 2FA 响应拦截器中,根据当前已注册的 `2fa.verify` 路由把它映射为当前 Settings 作用域下的 `/settings/2fa`。服务端响应结构和固定路径保持不变。
|
||||
|
||||
### 4.6 外部认证失败回跳无法识别 Settings shell
|
||||
|
||||
SAML、OIDC 和 CAS 的服务端 callback 使用 `resolveSigninPrefix()` 判断失败后应返回 V1 还是 modern V2 登录页。该判断目前只识别 modern prefix,不识别 `/settings/**`,因此外部认证失败时可能回到 `/signin`,而不是 `/settings/signin`。
|
||||
|
||||
外部认证成功时,回调目标仍应是原 Settings 深链,由 Settings SPA 中已存在的 `AuthProvider` 消费 callback 中的 token。
|
||||
|
||||
### 4.7 SSO 自动跳转和 multi-space 仍按旧登录路径识别
|
||||
|
||||
- SAML/OIDC 自动跳转 Provider 只把精确的 `/signin` 当作登录页。
|
||||
- multi-space 在早期请求阶段用固定认证路径列表判断是否跳过空间初始化。
|
||||
|
||||
两者都需要通过当前 Router 的认证路由或 Settings 认证路径识别 `/settings/signin` 等公共页面,避免登录页循环跳转或携带已失效的空间请求头。
|
||||
|
||||
## 5. 路由设计
|
||||
|
||||
### 5.1 路由名称保持不变
|
||||
|
||||
Settings SPA 继续使用插件已经注册的 route name,不引入新的认证路由协议:
|
||||
|
||||
| Route name | 普通 Client V2 path | Settings SPA path |
|
||||
| --- | --- | --- |
|
||||
| `auth.signin` | `/signin` | `/settings/signin` |
|
||||
| `auth.signup` | `/signup` | `/settings/signup` |
|
||||
| `auth.forgotPassword` | `/forgot-password` | `/settings/forgot-password` |
|
||||
| `auth.resetPassword` | `/reset-password` | `/settings/reset-password` |
|
||||
| `2fa.verify` | `/2fa` | `/settings/2fa` |
|
||||
|
||||
实际 document URL 再叠加 `APP_PUBLIC_PATH` 和当前应用作用域。
|
||||
|
||||
### 5.2 SettingsRouterManager 所有权
|
||||
|
||||
扩展 Settings Router 的内部所有权规则:
|
||||
|
||||
- 保留现有 `settings.*`、`settingsDetails.*` 和 fallback。
|
||||
- 接受 `auth`、`auth.*`、`2fa` 和 `2fa.*`。
|
||||
- route name、组件 loader、`skipAuthCheck` 和其他路由元数据保持不变。
|
||||
- 对认证族的绝对 path 加上 Settings route root;普通 `Application` 的 RouterManager 不做任何改动。
|
||||
- 不接受同一插件 lane 中的 Admin、public、mobile、multi-portal 或其他非认证路由。
|
||||
|
||||
路径根必须从当前 Settings Manager 动态获取,不重复硬编码 `/settings`,以保证内部实现仍与 Settings route namespace 一致。
|
||||
|
||||
### 5.3 认证组件内部导航
|
||||
|
||||
在 `plugin-auth/src/client-v2` 内增加不从包入口导出的内部路径解析逻辑:
|
||||
|
||||
- 优先从 `app.router` 中已注册的 route name 获取当前 path。
|
||||
- 普通 Client V2 得到 `/signin` 等原路径。
|
||||
- Settings Application 得到 `/settings/signin` 等重写后的路径。
|
||||
- Link、Navigate、延迟跳转和错误回退统一使用解析结果。
|
||||
- `redirect`、`name`、`resetToken`、`authenticator` 和 `error` 等查询参数继续保留。
|
||||
|
||||
不新增 `@nocobase/client-v2` 或 `@nocobase/plugin-auth/client-v2` 的公开导出。
|
||||
|
||||
### 5.4 登录页壳
|
||||
|
||||
`SettingsShell` 根据当前 Router match 判断页面所属分支:
|
||||
|
||||
- `auth.*` 和 `2fa.*`:直接渲染 children,不渲染 Settings Header、User Center、Help、侧栏或 embed container。
|
||||
- `settings.*` 和 `settingsDetails.*`:保持现有 Settings 壳行为。
|
||||
|
||||
登录页本身继续使用原 `AuthLayout`;2FA 继续使用原 `TwoFactorAuthLayout`。语言切换、系统标题、Powered by、主题 token 和认证器 UI 不复制、不分叉。
|
||||
|
||||
## 6. 跳转与会话设计
|
||||
|
||||
### 6.1 未登录和运行时 401
|
||||
|
||||
修改 `authRedirect.ts` 的独立 Settings 分支:
|
||||
|
||||
```text
|
||||
主应用:
|
||||
/settings/workflow?tab=list#recent
|
||||
-> /settings/signin?redirect=%2Fsettings%2Fworkflow%3Ftab%3Dlist%23recent
|
||||
|
||||
子应用:
|
||||
/settings/apps/demo/workflow
|
||||
-> /settings/apps/demo/signin?redirect=%2Fsettings%2Fapps%2Fdemo%2Fworkflow
|
||||
```
|
||||
|
||||
普通 Client V2 仍生成:
|
||||
|
||||
```text
|
||||
/v/signin
|
||||
/v/apps/demo/signin
|
||||
```
|
||||
|
||||
`resolveV2SigninRedirect()` 的同源白名单也改为按当前 Application 接受自己的登录页,拒绝其他应用或其他 runtime 的登录地址。
|
||||
|
||||
### 6.2 默认回跳
|
||||
|
||||
当 `/settings/signin` 没有 `redirect` 参数时:
|
||||
|
||||
- Settings Application 默认回到当前作用域的 Settings 根页。
|
||||
- 普通 Client V2 默认回到当前作用域的 `/admin`。
|
||||
|
||||
`SignInPage` 在规范化 `redirect` 时使用同一默认值,避免 UI 地址与最终提交行为不一致。
|
||||
|
||||
### 6.3 登录成功
|
||||
|
||||
- 本地账号、短信认证和 2FA 完成后继续使用 `useRedirect()`。
|
||||
- 已校验的 `/settings/**` 目标使用 `window.location.replace()` 做 document navigation,避免被其他 SPA basename 重写。
|
||||
- `redirect` 中的 query 和 hash 必须原样保留。
|
||||
- 无效、跨域、协议相对、路径穿越或指向其他子应用的目标回退到当前 Settings 根页。
|
||||
|
||||
### 6.4 退出、修改密码和会话过期
|
||||
|
||||
继续复用现有 `redirectToV2Signin()` 调用点,通过 Application-aware URL 解析改变目标:
|
||||
|
||||
- Settings → 当前作用域的 `/settings/signin`
|
||||
- 普通 V2 → 当前作用域的 `/v/signin`
|
||||
|
||||
覆盖入口包括:
|
||||
|
||||
- 初次 `/auth:check` 失败
|
||||
- API 响应 401 / `EXPIRED_SESSION`
|
||||
- User Center 退出登录
|
||||
- 修改密码成功后重新登录
|
||||
|
||||
服务端若返回不属于当前 Application 的旧登录地址,客户端拒绝该地址并回退到当前 Application 的登录页。
|
||||
|
||||
## 7. 认证功能闭环
|
||||
|
||||
### 7.1 账号密码、注册和密码重置
|
||||
|
||||
- Basic 登录和注册继续调用同一 `apiClient.auth`。
|
||||
- `/settings/signup`、`/settings/forgot-password`、`/settings/reset-password` 都保持 `skipAuthCheck`。
|
||||
- 从 Settings 忘记密码页发起请求时,SDK 现有 `baseURL` 计算会以 `/settings` 为根生成邮件地址;增加测试锁定邮件链接为 `/settings/reset-password?...`。
|
||||
- Reset token 检查失败时停留在 Settings reset 页面并显示原错误,不因 401 跳回登录页。
|
||||
- 注册完成、重置完成和“返回登录”链接统一回到当前 Application 的 signin route。
|
||||
|
||||
### 7.2 短信认证
|
||||
|
||||
短信认证仍通过 `plugin-auth-sms` 注册的 `signInFormLoader` 和共享 `useSignIn()`,不增加专用适配。验证 OTP 获取、提交、自动注册和回跳均可在 Settings signin 中完成。
|
||||
|
||||
### 7.3 2FA
|
||||
|
||||
Client V2 2FA 插件执行以下内部适配:
|
||||
|
||||
1. Settings Router 接受并重写 `2fa.*` 路由。
|
||||
2. 响应拦截器收到服务端 `/2fa?redirect=...` 后,使用当前 `2fa.verify` route path 构造 document URL。
|
||||
3. 2FA 过期时的“重新登录”按钮使用当前 `auth.signin` route path。
|
||||
4. 验证或绑定成功后复用 Application-aware `useRedirect()` 返回原 Settings 深链。
|
||||
|
||||
不修改 2FA API、服务端 302 payload 或 V1/V2 的原 `/2fa` 行为。
|
||||
|
||||
### 7.4 OIDC、SAML 和 CAS
|
||||
|
||||
成功流程:
|
||||
|
||||
- 登录按钮继续把规范化后的 Settings `redirect` 传给服务端。
|
||||
- callback 成功后返回原 Settings 路径并携带 token。
|
||||
- Settings SPA 的 `AuthProvider` 在 CurrentUserProvider 之前消费 token,清理 URL 后继续渲染目标页面。
|
||||
|
||||
失败流程:
|
||||
|
||||
- 扩展服务端共享 URL 解析,使其识别主应用、`apps` 和 `_app` 三类 Settings redirect。
|
||||
- SSO 失败时返回当前作用域的 `/settings/signin`,保留 `redirect`、`authenticator` 和 `error`。
|
||||
- 普通 V1/V2 的失败回跳规则保持不变。
|
||||
|
||||
如果 callback 目标原本包含 query/hash,构造 token/error 参数时必须使用 URL 解析和参数合并,不能直接追加第二个 `?` 或把参数追加到 hash 后面。
|
||||
|
||||
### 7.5 SAML/OIDC 自动跳转
|
||||
|
||||
自动跳转 Provider 用 Router match 识别 `auth.signin`,而不是只比较字面值 `/signin`:
|
||||
|
||||
- 在 `/settings/signin` 上不再次触发自动 SSO 检查。
|
||||
- 在 Settings 受保护页面仍保持原自动跳转能力。
|
||||
- 带 callback token 的目标页先交给 `AuthProvider` 消费 token,避免循环。
|
||||
|
||||
### 7.6 multi-space
|
||||
|
||||
multi-space 的早期请求判断同时识别普通和 Settings 认证公共路由:
|
||||
|
||||
- signin、signup、forgot-password、reset-password 和 2FA 页面不启动空间 bootstrap。
|
||||
- 未登录请求不携带空间 header。
|
||||
- 普通 `/v/**` 和已登录 Settings 页面原行为不变。
|
||||
|
||||
## 8. 后端与 API 边界
|
||||
|
||||
完整覆盖外部认证需要把后端改动严格限制在认证 URL 构造层,不修改认证业务逻辑或接口协议:
|
||||
|
||||
1. 扩展 `plugin-auth` 的共享 signin prefix 解析,使 Settings-shaped redirect 返回 Settings signin prefix。
|
||||
2. SAML、OIDC、CAS callback 在目标已有 query/hash 时安全合并 token/error 参数,保证 Settings 深链不被破坏。
|
||||
|
||||
推荐方案:在共享 URL resolver 中识别已经通过安全校验的 Settings redirect,供 SAML、OIDC、CAS 继续复用。
|
||||
|
||||
不推荐的替代方案:
|
||||
|
||||
- 让 Gateway 根据 `redirect` query 再把 `/signin` 重定向到 `/settings/signin`:Gateway 会开始理解认证协议,耦合更高。
|
||||
- 外部认证失败仍回 `/v/signin`:功能可用但不满足 Settings 自持完整登录流程。
|
||||
- 为 Settings 新增独立认证 API 或 callback 协议:没有必要,且会扩大兼容面。
|
||||
|
||||
这些调整不改变 HTTP API payload、数据库或插件注册 API,但会扩展既有路由解析行为。开始实现前应按仓库 API 规则确认采用推荐方案;如果共享 helper 需要新增参数或导出,则必须单独提出 API 方案并获得确认,默认实现应优先保持现有函数签名。
|
||||
|
||||
2FA 不需要后端改动;其固定 `/2fa` 响应由 Client V2 插件根据当前 route record 做 Application-aware 映射。
|
||||
|
||||
## 9. 构建、Gateway 与开发环境
|
||||
|
||||
现有 Gateway 和开发代理已经按 `/settings/**` 返回 Settings HTML,其中包括 `/settings/apps/:app/**` 和 `/settings/_app/:app/**`,因此认证子路由原则上不需要新的构建 stage 或代理分支。
|
||||
|
||||
仍需增加回归测试证明:
|
||||
|
||||
- `/settings/signin`、signup、forgot-password、reset-password 和 2FA 深链返回 Settings HTML。
|
||||
- 两种子应用路径返回 Settings HTML。
|
||||
- `APP_PUBLIC_PATH` 下的路径正确。
|
||||
- `/settings/assets/**`、CDN asset prefix 和长期缓存策略不变。
|
||||
- `/v/signin` 仍返回 Client V2 HTML;`/signin` 和 V1 页面分流不变。
|
||||
- Settings dev proxy 接受全部认证 document path,且不会把 assets 当作 document 代理。
|
||||
|
||||
不新增 Settings 构建产物,继续使用:
|
||||
|
||||
```text
|
||||
dist/client/settings/index.html
|
||||
dist/client/settings/assets/**
|
||||
```
|
||||
|
||||
## 10. 风险 TDD 实施顺序
|
||||
|
||||
### 阶段 A:先建立核心红测
|
||||
|
||||
先修改或新增测试,但不改实现,确认以下断言按预期失败:
|
||||
|
||||
1. `authRedirect.test.ts`
|
||||
- Settings 构造 `/settings/signin`。
|
||||
- 主应用、`apps`、`_app` 和 `APP_PUBLIC_PATH` 矩阵。
|
||||
- 普通 V2 `/v/signin` 锁定不变。
|
||||
- 同源、跨应用和恶意 redirect 校验。
|
||||
2. `settings-application.test.ts`
|
||||
- Settings Router 接受并重写 `auth.*` 和 `2fa.*`。
|
||||
- Admin/public/mobile 路由仍被过滤。
|
||||
3. `settings-layout-root.test.tsx`、`settings-shell.test.tsx`
|
||||
- 初始鉴权失败进入 Settings signin。
|
||||
- auth/2FA 页面不显示 Settings Header。
|
||||
- 普通 Settings 页面仍显示原壳。
|
||||
|
||||
红测必须因当前仍生成 `/v/signin`、过滤 auth route 或显示 SettingsShell 而失败;若没有按预期失败,先修正测试再进入实现。
|
||||
|
||||
### 阶段 B:核心路由和跳转实现
|
||||
|
||||
实现:
|
||||
|
||||
- Settings Router 认证路由所有权和 path 重写。
|
||||
- Settings Application signin URL 构造。
|
||||
- redirect 默认值、安全校验和跨应用约束。
|
||||
- SettingsShell 对 auth/2FA 分支的壳隔离。
|
||||
|
||||
随后重跑阶段 A 的同一批测试并确认绿色。
|
||||
|
||||
### 阶段 C:plugin-auth 完整页面族
|
||||
|
||||
先为以下行为增加红测:
|
||||
|
||||
- 登录页内部注册链接和忘记密码链接。
|
||||
- signup、forgot-password、reset-password 返回登录。
|
||||
- 无 `redirect` 时 Settings 默认回 Settings 根页。
|
||||
- 重置邮件链接位于 Settings route root。
|
||||
- Reset token 失效不触发登录循环。
|
||||
|
||||
再把硬编码路径改为当前 Router route record 派生,并运行:
|
||||
|
||||
- `SignInPage.test.tsx`
|
||||
- `hooks.test.tsx`
|
||||
- `plugin.test.tsx`
|
||||
- 新增的表单/页面路由测试
|
||||
- `lostPassword.test.ts`
|
||||
- `resetPassword.test.ts`
|
||||
|
||||
服务端测试按仓库规则串行运行。
|
||||
|
||||
### 阶段 D:退出、修改密码、401 和 multi-space
|
||||
|
||||
覆盖:
|
||||
|
||||
- `plugin-users` 的 SignOut 和 ChangePassword。
|
||||
- `plugin-auth` 初始鉴权失败及运行时 401。
|
||||
- multi-space 在 Settings 公共认证页跳过 bootstrap/header。
|
||||
- 普通 V2 对应测试继续保持原断言。
|
||||
|
||||
### 阶段 E:2FA
|
||||
|
||||
先增加失败测试锁定:
|
||||
|
||||
- Settings 中服务端 `/2fa` 被映射到 `/settings/2fa`。
|
||||
- query 中的原 redirect 完整保留。
|
||||
- 2FA 过期回 `/settings/signin`。
|
||||
- 普通 V2 仍为 `/v/2fa`,V1 行为不变。
|
||||
|
||||
实现 client-v2 插件适配后,运行 2FA client-v2 单测和相关服务端回归测试。
|
||||
|
||||
### 阶段 F:OIDC、SAML、CAS
|
||||
|
||||
先为每种认证增加成功和失败红测:
|
||||
|
||||
- Settings 成功 callback 回原深链并被 `AuthProvider` 消费 token。
|
||||
- Settings 失败 callback 回当前作用域 `/settings/signin`。
|
||||
- main、`apps`、`_app`、`APP_PUBLIC_PATH`。
|
||||
- query/hash 与 callback 参数正确合并。
|
||||
- 原 V1 和 `/v` callback 断言不变。
|
||||
- SAML/OIDC 自动跳转在 Settings signin 上不会循环。
|
||||
|
||||
再实现共享服务端 URL 解析和两个自动跳转 Provider 的 route-aware 判定。
|
||||
|
||||
### 阶段 G:基础设施与构建回归
|
||||
|
||||
运行并补充:
|
||||
|
||||
- Gateway Settings 分流测试。
|
||||
- Settings dev proxy 测试。
|
||||
- Settings runtime scope/public path 测试。
|
||||
- 独立 Settings Rsbuild 构建。
|
||||
- 相关 build-stage 测试。
|
||||
|
||||
确认没有增加新的 HTML、assets 目录或复制协议。
|
||||
|
||||
## 11. 浏览器验收矩阵
|
||||
|
||||
### 11.1 基础路由
|
||||
|
||||
| 场景 | 入口 | 预期登录页 | 登录后 |
|
||||
| --- | --- | --- | --- |
|
||||
| 主应用 | `/settings/system-settings` | `/settings/signin` | 回原页 |
|
||||
| `apps` 子应用 | `/settings/apps/demo/system-settings` | `/settings/apps/demo/signin` | 回原页 |
|
||||
| `_app` 子应用 | `/settings/_app/demo/system-settings` | `/settings/_app/demo/signin` | 回原页 |
|
||||
| public path | `/nocobase/settings/system-settings` | `/nocobase/settings/signin` | 回原页 |
|
||||
| 普通 V2 | `/v/admin` | `/v/signin` | 回 `/v/admin` |
|
||||
|
||||
每个场景至少使用一个包含 query/hash 的深链并验证返回值完全一致。
|
||||
|
||||
### 11.2 功能验收
|
||||
|
||||
- Basic 正确密码、错误密码及错误提示。
|
||||
- 多认证器 Tabs、无可用认证器空态。
|
||||
- 注册成功后回 Settings signin。
|
||||
- 忘记密码邮件指向 Settings reset 页面。
|
||||
- Reset token 有效、无效和过期三种状态。
|
||||
- SMS 获取验证码、登录和自动注册。
|
||||
- 2FA 已绑定验证、首次绑定、过期后重新登录。
|
||||
- OIDC、SAML、CAS 成功、取消/失败及 callback token 清理。
|
||||
- SAML/OIDC auto redirect 无循环。
|
||||
- 会话过期、401、退出和修改密码后重新登录。
|
||||
- Email OAuth Settings callback 在登录后准确返回原 callback URL。
|
||||
|
||||
### 11.3 非回归验收
|
||||
|
||||
- `/v/signin` 全部原功能仍可用。
|
||||
- Client V1 登录、`/admin/settings/**` 和 V1 Workflow/OAuth 不变。
|
||||
- Settings 正常页面仍显示精简顶栏;auth/2FA 页面不显示。
|
||||
- Workflow、AI、Mail OAuth 等 Settings 全宽详情的鉴权回跳不变。
|
||||
- Portal task center、Mail manager、API docs、mobile 和 embed 路由不被 Settings Router 接管。
|
||||
|
||||
## 12. 预计触及范围
|
||||
|
||||
### 主仓库
|
||||
|
||||
- `packages/core/client-v2/src/authRedirect.ts`
|
||||
- `packages/core/client-v2/src/settings-app/SettingsRouterManager.ts`
|
||||
- `packages/core/client-v2/src/settings-app/SettingsShell.tsx`
|
||||
- `packages/plugins/@nocobase/plugin-auth/src/client-v2/**`
|
||||
- `packages/plugins/@nocobase/plugin-auth/src/server/utils/buildRedirectPath.ts`
|
||||
- `packages/plugins/@nocobase/plugin-users/src/client-v2/**`
|
||||
- 对应单元测试、Gateway/dev proxy 回归测试
|
||||
|
||||
### `packages/pro-plugins` 仓库
|
||||
|
||||
- `@nocobase/plugin-two-factor-authentication/src/client-v2/**`
|
||||
- `@nocobase/plugin-auth-saml/src/client-v2/**`
|
||||
- `@nocobase/plugin-auth-saml/src/server/actions/**`
|
||||
- `@nocobase/plugin-auth-saml/src/server/__tests__/**`
|
||||
- `@nocobase/plugin-auth-cas/src/server/actions/**`
|
||||
- `@nocobase/plugin-auth-cas/src/server/__tests__/**`
|
||||
- `@nocobase/plugin-multi-space/src/client-v2/**`
|
||||
|
||||
### 独立 OIDC 仓库
|
||||
|
||||
- `packages/pro-plugins/plugin-auth-oidc/src/client-v2/**`
|
||||
- `packages/pro-plugins/plugin-auth-oidc/src/server/actions/**`
|
||||
- `packages/pro-plugins/plugin-auth-oidc/src/server/__tests__/**`
|
||||
|
||||
实施前先确认各仓库工作树;若需要分别提交 PR,所有仓库使用同一分支名,并分别完成红绿测试和 lint。
|
||||
|
||||
## 13. 完成门槛
|
||||
|
||||
只有同时满足以下条件才可结束任务:
|
||||
|
||||
1. 所有风险行为均有先红后绿证据。
|
||||
2. 主仓库、pro-plugins 和 OIDC 仓库相关单测通过。
|
||||
3. Settings 独立构建成功。
|
||||
4. Gateway/dev proxy/public path 测试通过。
|
||||
5. 浏览器矩阵完成,至少留存关键 URL 和结果证据。
|
||||
6. 所有触及的 TypeScript/TSX 文件执行 `yarn eslint --fix`,无新增 lint/type 错误。
|
||||
7. 明确记录所有跳过的外部认证实测及原因;没有真实 IdP 环境时,必须以服务端 callback 测试和浏览器 mock 回归替代,不能直接标记为已实测。
|
||||
8. 最终报告分别列出红测、绿测、构建、浏览器回归命令和结果。
|
||||
|
||||
## 14. 实施前确认项
|
||||
|
||||
本计划推荐并依赖以下唯一的路由行为决策:
|
||||
|
||||
> 当 SAML、OIDC 或 CAS 的安全 redirect 指向当前应用的 `/settings/**` 时,服务端认证失败 callback 返回同一应用作用域的 `/settings/signin`;其他 redirect 继续按原 V1/V2 规则处理。
|
||||
|
||||
确认该决策后即可按上述 TDD 顺序实施,不需要新增公开 API 或数据迁移。
|
||||
@@ -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 path from 'node:path';
|
||||
import { IndexGenerator } from '../../devtools/common.js';
|
||||
|
||||
type V2PluginIndexGenerator = new (
|
||||
outputPath: string,
|
||||
pluginPaths: string[],
|
||||
options: {
|
||||
clientModuleName: string;
|
||||
clientRootFile: string;
|
||||
clientSourceDir: string;
|
||||
},
|
||||
) => { generate(): void };
|
||||
|
||||
function getPluginDirectories() {
|
||||
return (process.env.PLUGIN_PATH || 'packages/plugins/,packages/samples/,packages/pro-plugins/')
|
||||
.split(',')
|
||||
.map((directory) => path.resolve(process.cwd(), directory));
|
||||
}
|
||||
|
||||
export function generateSettingsPluginImports(outputPath: string, pluginPaths = getPluginDirectories()) {
|
||||
const Generator = IndexGenerator as unknown as V2PluginIndexGenerator;
|
||||
new Generator(outputPath, pluginPaths, {
|
||||
clientModuleName: 'client-v2',
|
||||
clientRootFile: 'client-v2.js',
|
||||
clientSourceDir: 'client-v2',
|
||||
}).generate();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Loading...</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 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 path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from '@rsbuild/core';
|
||||
import { pluginLess } from '@rsbuild/plugin-less';
|
||||
import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill';
|
||||
import { pluginReact } from '@rsbuild/plugin-react';
|
||||
import { pluginSvgr } from '@rsbuild/plugin-svgr';
|
||||
import { getRsbuildBrowserAlias } from '@nocobase/devtools/rsbuildConfig';
|
||||
import { generateSettingsPluginImports } from './generatePluginImports';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const SETTINGS_DIST_DIR = 'settings';
|
||||
|
||||
process.env.APP_PACKAGE_ROOT ||= path.resolve(__dirname, '..');
|
||||
generateSettingsPluginImports(path.resolve(__dirname, 'src/.plugins'));
|
||||
|
||||
function ensurePublicPath(value: string | undefined, fallback = '/') {
|
||||
let normalized = value || fallback;
|
||||
if (!normalized.startsWith('/')) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
if (!normalized.endsWith('/')) {
|
||||
normalized = `${normalized}/`;
|
||||
}
|
||||
return normalized.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
function toNumber(value: string | undefined, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeModernClientPrefix(value: string | undefined) {
|
||||
const normalized = String(value || 'v')
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
return normalized || 'v';
|
||||
}
|
||||
|
||||
function assertAvailableModernClientPrefix(value: string | undefined) {
|
||||
if (normalizeModernClientPrefix(value) === SETTINGS_DIST_DIR) {
|
||||
throw new Error('APP_MODERN_CLIENT_PREFIX "settings" is reserved for the standalone Settings application.');
|
||||
}
|
||||
}
|
||||
|
||||
function createRuntimeHeadScript(appPublicPath: string, isBuild: boolean) {
|
||||
return [
|
||||
`window['__nocobase_public_path__'] = window['__nocobase_public_path__'] ?? ${JSON.stringify(appPublicPath)};`,
|
||||
`window['__nocobase_modern_client_prefix__'] = window['__nocobase_modern_client_prefix__'] ?? ${JSON.stringify(
|
||||
normalizeModernClientPrefix(process.env.APP_MODERN_CLIENT_PREFIX),
|
||||
)};`,
|
||||
`window['__webpack_public_path__'] = window['__webpack_public_path__'] ?? ${JSON.stringify(
|
||||
isBuild ? process.env.CDN_BASE_URL || '' : '',
|
||||
)};`,
|
||||
`window['__nocobase_api_base_url__'] = window['__nocobase_api_base_url__'] ?? ${JSON.stringify(
|
||||
process.env.API_BASE_URL || process.env.API_BASE_PATH || '',
|
||||
)};`,
|
||||
`window['__nocobase_api_client_storage_prefix__'] = window['__nocobase_api_client_storage_prefix__'] ?? ${JSON.stringify(
|
||||
process.env.API_CLIENT_STORAGE_PREFIX || '',
|
||||
)};`,
|
||||
`window['__nocobase_api_client_storage_type__'] = window['__nocobase_api_client_storage_type__'] ?? ${JSON.stringify(
|
||||
process.env.API_CLIENT_STORAGE_TYPE || '',
|
||||
)};`,
|
||||
`window['__nocobase_api_client_share_token__'] = window['__nocobase_api_client_share_token__'] ?? ${JSON.stringify(
|
||||
process.env.API_CLIENT_SHARE_TOKEN || 'false',
|
||||
)};`,
|
||||
`window['__nocobase_ws_url__'] = window['__nocobase_ws_url__'] ?? ${JSON.stringify(
|
||||
process.env.WEBSOCKET_URL || '',
|
||||
)};`,
|
||||
`window['__nocobase_ws_path__'] = window['__nocobase_ws_path__'] ?? ${JSON.stringify(process.env.WS_PATH || '')};`,
|
||||
`window['__nocobase_app_dev__'] = window['__nocobase_app_dev__'] ?? ${JSON.stringify(
|
||||
process.env.NOCOBASE_APP_DEV === 'true',
|
||||
)};`,
|
||||
`window['__esm_cdn_base_url__'] = window['__esm_cdn_base_url__'] ?? ${JSON.stringify(
|
||||
process.env.ESM_CDN_BASE_URL || 'https://esm.sh',
|
||||
)};`,
|
||||
`window['__esm_cdn_suffix__'] = window['__esm_cdn_suffix__'] ?? ${JSON.stringify(
|
||||
process.env.ESM_CDN_SUFFIX || '',
|
||||
)};`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function createDefineValues(appPublicPath: string) {
|
||||
return {
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
|
||||
'process.env.API_BASE_URL': JSON.stringify(process.env.API_BASE_URL || process.env.API_BASE_PATH || ''),
|
||||
'import.meta.env.APP_PUBLIC_PATH': JSON.stringify(appPublicPath),
|
||||
'import.meta.env.API_BASE_URL': JSON.stringify(process.env.API_BASE_URL || process.env.API_BASE_PATH || ''),
|
||||
'import.meta.env.API_CLIENT_STORAGE_PREFIX': JSON.stringify(process.env.API_CLIENT_STORAGE_PREFIX || ''),
|
||||
'import.meta.env.API_CLIENT_STORAGE_TYPE': JSON.stringify(process.env.API_CLIENT_STORAGE_TYPE || ''),
|
||||
'import.meta.env.API_CLIENT_SHARE_TOKEN': JSON.stringify(process.env.API_CLIENT_SHARE_TOKEN || 'false'),
|
||||
'import.meta.env.WS_URL': JSON.stringify(process.env.WEBSOCKET_URL || ''),
|
||||
'import.meta.env.WS_PATH': JSON.stringify(process.env.WS_PATH || ''),
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ command }) => {
|
||||
const isBuild = command === 'build';
|
||||
assertAvailableModernClientPrefix(process.env.APP_MODERN_CLIENT_PREFIX);
|
||||
|
||||
const appPublicPath = ensurePublicPath(process.env.APP_PUBLIC_PATH, '/');
|
||||
const settingsPublicPath = isBuild
|
||||
? `/${SETTINGS_DIST_DIR}/`
|
||||
: ensurePublicPath(`${appPublicPath}${SETTINGS_DIST_DIR}/`);
|
||||
const apiBasePath = ensurePublicPath(process.env.API_BASE_PATH, '/api/');
|
||||
const fileBasePath = ensurePublicPath(`${appPublicPath}files/`);
|
||||
const localStorageBasePath = ensurePublicPath(`${appPublicPath}storage/uploads/`);
|
||||
const staticBasePath = ensurePublicPath(`${appPublicPath}static/`);
|
||||
const wsBasePath = ensurePublicPath(process.env.WS_PATH, '/ws/');
|
||||
const appPort = toNumber(process.env.APP_PORT, 13001);
|
||||
const settingsPort = toNumber(process.env.APP_SETTINGS_PORT, appPort + 3);
|
||||
const hmrClientHost = process.env.RSPACK_HMR_CLIENT_HOST;
|
||||
const hmrClientPort = toNumber(process.env.RSPACK_HMR_CLIENT_PORT || process.env.APP_PORT, settingsPort);
|
||||
const proxyTargetUrl = process.env.PROXY_TARGET_URL || `http://127.0.0.1:${appPort}`;
|
||||
const workspaceAliases = getRsbuildBrowserAlias();
|
||||
|
||||
return {
|
||||
plugins: [pluginReact(), pluginLess(), pluginNodePolyfill(), pluginSvgr()],
|
||||
resolve: {
|
||||
alias: workspaceAliases,
|
||||
},
|
||||
source: {
|
||||
entry: {
|
||||
index: path.resolve(__dirname, 'src/main.tsx'),
|
||||
},
|
||||
tsconfigPath: path.resolve(__dirname, 'tsconfig.json'),
|
||||
define: createDefineValues(appPublicPath),
|
||||
},
|
||||
html: {
|
||||
template: path.resolve(__dirname, 'index.html'),
|
||||
scriptLoading: isBuild ? 'module' : 'defer',
|
||||
tags: [
|
||||
{
|
||||
tag: 'link',
|
||||
attrs: {
|
||||
rel: 'stylesheet',
|
||||
href: `${settingsPublicPath}global.css`,
|
||||
},
|
||||
publicPath: false,
|
||||
head: true,
|
||||
append: false,
|
||||
},
|
||||
{
|
||||
tag: 'script',
|
||||
children: createRuntimeHeadScript(appPublicPath, isBuild),
|
||||
head: true,
|
||||
append: false,
|
||||
},
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: {
|
||||
src: `${settingsPublicPath}browser-checker.js?v=1`,
|
||||
},
|
||||
publicPath: false,
|
||||
head: true,
|
||||
append: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
output: {
|
||||
target: 'web',
|
||||
distPath: {
|
||||
root: path.resolve(__dirname, '../dist/client/settings'),
|
||||
js: 'assets',
|
||||
jsAsync: 'assets',
|
||||
css: 'assets',
|
||||
cssAsync: 'assets',
|
||||
svg: 'assets',
|
||||
font: 'assets',
|
||||
image: 'assets',
|
||||
media: 'assets',
|
||||
},
|
||||
filename: {
|
||||
js: '[name]-[contenthash:8].js',
|
||||
css: '[name]-[contenthash:8].css',
|
||||
svg: '[name]-[contenthash:8][ext][query]',
|
||||
font: '[name]-[contenthash:8][ext][query]',
|
||||
image: '[name]-[contenthash:8][ext][query]',
|
||||
media: '[name]-[contenthash:8][ext][query]',
|
||||
},
|
||||
assetPrefix: settingsPublicPath,
|
||||
cleanDistPath: true,
|
||||
sourceMap: {
|
||||
js: isBuild ? false : 'eval-cheap-module-source-map',
|
||||
css: false,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
base: settingsPublicPath,
|
||||
host: '0.0.0.0',
|
||||
port: settingsPort,
|
||||
compress: true,
|
||||
publicDir: {
|
||||
name: path.resolve(__dirname, '../client-v2/public'),
|
||||
},
|
||||
proxy: {
|
||||
[apiBasePath]: {
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
xfwd: true,
|
||||
},
|
||||
[localStorageBasePath]: {
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
[fileBasePath]: {
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
[staticBasePath]: {
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
[wsBasePath]: {
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
xfwd: true,
|
||||
},
|
||||
},
|
||||
historyApiFallback: {
|
||||
disableDotRule: true,
|
||||
index: `${settingsPublicPath}index.html`,
|
||||
},
|
||||
},
|
||||
dev: {
|
||||
assetPrefix: settingsPublicPath,
|
||||
lazyCompilation: false,
|
||||
client: {
|
||||
overlay: false,
|
||||
protocol: 'ws',
|
||||
host: hmrClientHost,
|
||||
port: hmrClientPort,
|
||||
path: `${settingsPublicPath.replace(/\/$/, '')}/__rspack_hmr`,
|
||||
},
|
||||
progressBar: true,
|
||||
},
|
||||
tools: {
|
||||
rspack(config) {
|
||||
config.target = ['web', 'es2020'];
|
||||
config.output.module = isBuild;
|
||||
config.output.chunkFormat = isBuild ? 'module' : 'array-push';
|
||||
config.experiments = {
|
||||
...config.experiments,
|
||||
outputModule: isBuild,
|
||||
};
|
||||
config.optimization = {
|
||||
...config.optimization,
|
||||
runtimeChunk: 'single',
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
},
|
||||
};
|
||||
config.performance = false;
|
||||
config.stats = 'errors-warnings';
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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 { Plugin, type PluginClass } from '@nocobase/client-v2';
|
||||
import { CollectionPluginV2 } from '../../../../presets/nocobase/src/client-v2/CollectionPluginV2';
|
||||
import { SettingsApplication } from '../../../client-v2/src/settings-app/SettingsApplication';
|
||||
import { SettingsBuildInPlugin } from '../../../client-v2/src/settings-app/SettingsBuildInPlugin';
|
||||
import { resolveSettingsRuntimeScope } from './runtimeScope';
|
||||
|
||||
const CollectionPluginClass = CollectionPluginV2 as unknown as PluginClass;
|
||||
const SettingsBuildInPluginClass = SettingsBuildInPlugin as unknown as PluginClass;
|
||||
|
||||
function offsetToTimeZone(offset: number) {
|
||||
const hours = Math.floor(Math.abs(offset));
|
||||
const minutes = Math.abs((offset % 1) * 60);
|
||||
const formattedHours = String(hours).padStart(2, '0');
|
||||
const formattedMinutes = String(minutes).padStart(2, '0');
|
||||
const sign = offset >= 0 ? '+' : '-';
|
||||
return `${sign}${formattedHours}:${formattedMinutes}`;
|
||||
}
|
||||
|
||||
function getCurrentTimezone() {
|
||||
return offsetToTimeZone(new Date().getTimezoneOffset() / -60);
|
||||
}
|
||||
|
||||
export class SettingsPresetPlugin extends Plugin<object, SettingsApplication> {
|
||||
private getHostname() {
|
||||
if (process.env.API_BASE_URL) {
|
||||
try {
|
||||
return new URL(process.env.API_BASE_URL).hostname;
|
||||
} catch {
|
||||
// Fall back to the document hostname when API_BASE_URL is relative or invalid.
|
||||
}
|
||||
}
|
||||
return window.location.hostname;
|
||||
}
|
||||
|
||||
async afterAdd() {
|
||||
const { basename } = resolveSettingsRuntimeScope(this.app.getPublicPath(), window.location.pathname);
|
||||
this.router.setType('browser');
|
||||
this.router.setBasename(basename);
|
||||
this.app.apiClient.axios.interceptors.request.use((config) => {
|
||||
config.headers['X-Hostname'] = this.getHostname();
|
||||
config.headers['X-Timezone'] = getCurrentTimezone();
|
||||
return config;
|
||||
});
|
||||
await this.app.pm.add(CollectionPluginClass, { name: 'builtin-collection-v2' });
|
||||
await this.app.pm.add(SettingsBuildInPluginClass, { name: 'builtin-settings-v2' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import { resolveSettingsAssetPublicPath } from '../runtimePublicPath';
|
||||
|
||||
describe('Settings runtime asset public path', () => {
|
||||
it('uses the Settings asset path without a CDN', () => {
|
||||
expect(resolveSettingsAssetPublicPath(undefined, '/nocobase/')).toBe('/nocobase/settings/');
|
||||
});
|
||||
|
||||
it('keeps Settings assets isolated under a CDN', () => {
|
||||
expect(resolveSettingsAssetPublicPath('https://cdn.example.com/releases/42/', '/nocobase/')).toBe(
|
||||
'https://cdn.example.com/releases/42/settings/',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import { resolveSettingsRuntimeScope } from '../runtimeScope';
|
||||
|
||||
describe('Settings runtime scope', () => {
|
||||
it('keeps the main application on the configured public path', () => {
|
||||
expect(resolveSettingsRuntimeScope('/nocobase/', '/nocobase/settings/system-settings')).toEqual({
|
||||
appName: undefined,
|
||||
basename: '/nocobase/',
|
||||
rootPublicPath: '/nocobase/',
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['apps', '_app'])('derives %s application scope from the document path', (scope) => {
|
||||
expect(resolveSettingsRuntimeScope('/nocobase/', `/nocobase/settings/${scope}/demo/workflow`)).toEqual({
|
||||
appName: 'demo',
|
||||
basename: `/nocobase/settings/${scope}/demo/`,
|
||||
rootPublicPath: '/nocobase/',
|
||||
});
|
||||
});
|
||||
});
|
||||
+22
@@ -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.
|
||||
*/
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly APP_PUBLIC_PATH?: string;
|
||||
readonly API_BASE_URL?: string;
|
||||
readonly API_CLIENT_SHARE_TOKEN?: string;
|
||||
readonly API_CLIENT_STORAGE_PREFIX?: string;
|
||||
readonly API_CLIENT_STORAGE_TYPE?: string;
|
||||
readonly WS_URL?: string;
|
||||
readonly WS_PATH?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 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 devDynamicImport from './.plugins';
|
||||
import type { PluginClass } from '@nocobase/client-v2';
|
||||
import { SettingsApplication } from '../../../client-v2/src/settings-app/SettingsApplication';
|
||||
import { SettingsPresetPlugin } from './SettingsPresetPlugin';
|
||||
import { resolveSettingsAssetPublicPath } from './runtimePublicPath';
|
||||
import { resolveSettingsRuntimeScope } from './runtimeScope';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__nocobase_public_path__?: string;
|
||||
__webpack_public_path__?: string;
|
||||
__nocobase_api_base_url__?: string;
|
||||
__nocobase_api_client_storage_prefix__?: string;
|
||||
__nocobase_api_client_storage_type__?: string;
|
||||
__nocobase_api_client_share_token__?: boolean | string;
|
||||
__nocobase_ws_url__?: string;
|
||||
__nocobase_ws_path__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
function parseShareToken(value: boolean | string | undefined) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
return String(value || '').toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
type ClientStorageType = 'localStorage' | 'sessionStorage' | 'memory';
|
||||
|
||||
function parseStorageType(value: string | undefined): ClientStorageType {
|
||||
if (value === 'sessionStorage' || value === 'memory' || value === 'localStorage') {
|
||||
return value;
|
||||
}
|
||||
return 'localStorage';
|
||||
}
|
||||
|
||||
const configuredPublicPath = window.__nocobase_public_path__ || import.meta.env.APP_PUBLIC_PATH || '/';
|
||||
const runtimeScope = resolveSettingsRuntimeScope(configuredPublicPath, window.location.pathname);
|
||||
const SettingsPresetPluginClass = SettingsPresetPlugin as unknown as PluginClass;
|
||||
|
||||
declare let __webpack_public_path__: string;
|
||||
// eslint-disable-next-line prefer-const
|
||||
__webpack_public_path__ = resolveSettingsAssetPublicPath(window.__webpack_public_path__, runtimeScope.rootPublicPath);
|
||||
|
||||
const app = new SettingsApplication({
|
||||
name: runtimeScope.appName,
|
||||
publicPath: runtimeScope.rootPublicPath,
|
||||
router: {
|
||||
basename: runtimeScope.basename,
|
||||
},
|
||||
apiClient: {
|
||||
shareToken: parseShareToken(window.__nocobase_api_client_share_token__ || import.meta.env.API_CLIENT_SHARE_TOKEN),
|
||||
storageType: parseStorageType(
|
||||
window.__nocobase_api_client_storage_type__ || import.meta.env.API_CLIENT_STORAGE_TYPE,
|
||||
),
|
||||
storagePrefix:
|
||||
window.__nocobase_api_client_storage_prefix__ || import.meta.env.API_CLIENT_STORAGE_PREFIX || 'NOCOBASE_',
|
||||
baseURL: window.__nocobase_api_base_url__ || import.meta.env.API_BASE_URL || `${runtimeScope.rootPublicPath}api/`,
|
||||
},
|
||||
ws: {
|
||||
url: window.__nocobase_ws_url__ || import.meta.env.WS_URL || '',
|
||||
basename: window.__nocobase_ws_path__ || import.meta.env.WS_PATH || `${runtimeScope.rootPublicPath}ws`,
|
||||
},
|
||||
loadRemotePlugins: true,
|
||||
devDynamicImport,
|
||||
plugins: [SettingsPresetPluginClass],
|
||||
});
|
||||
|
||||
app.mount('#root');
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
function normalizeBasePath(value: string | undefined, fallback: string) {
|
||||
const normalized = String(value || fallback)
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
return normalized || fallback.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function resolveSettingsAssetPublicPath(cdnBaseUrl: string | undefined, appPublicPath: string) {
|
||||
const basePath = cdnBaseUrl?.trim() ? normalizeBasePath(cdnBaseUrl, '/') : normalizeBasePath(appPublicPath, '/');
|
||||
|
||||
return `${basePath}/settings/`.replace(/^\/\//, '/');
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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 {
|
||||
resolveSettingsDocumentBasename,
|
||||
type SettingsAppScope,
|
||||
} from '../../../client-v2/src/settings-app/settingsDocumentPath';
|
||||
|
||||
function ensurePublicPath(value: string) {
|
||||
let normalized = value.trim() || '/';
|
||||
if (!normalized.startsWith('/')) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
if (!normalized.endsWith('/')) {
|
||||
normalized = `${normalized}/`;
|
||||
}
|
||||
return normalized.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
export function resolveSettingsRuntimeScope(configuredPublicPath: string, pathname: string) {
|
||||
const rootPublicPath = ensurePublicPath(configuredPublicPath);
|
||||
const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
||||
const relativePathname = normalizedPathname.startsWith(rootPublicPath)
|
||||
? normalizedPathname.slice(rootPublicPath.length)
|
||||
: normalizedPathname.replace(/^\/+/, '');
|
||||
const match = /^settings\/(apps|_app)\/([^/]+)(?:\/|$)/.exec(relativePathname);
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
appName: undefined,
|
||||
basename: rootPublicPath,
|
||||
rootPublicPath,
|
||||
};
|
||||
}
|
||||
|
||||
const [, scope, appName] = match;
|
||||
const appScope = `/${scope}/${appName}` as SettingsAppScope;
|
||||
return {
|
||||
appName,
|
||||
basename: resolveSettingsDocumentBasename(rootPublicPath, appScope),
|
||||
rootPublicPath,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"importHelpers": true,
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": "../../../../",
|
||||
"strict": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -17,11 +17,82 @@ function normalizePublicPath(value) {
|
||||
return ensureTrailingSlash(normalized);
|
||||
}
|
||||
|
||||
function normalizePathname(value) {
|
||||
const normalized = ensureLeadingSlash(String(value || '/').trim() || '/').replace(/\/{2,}/g, '/');
|
||||
return normalized === '/' ? normalized : normalized.replace(/\/+$/g, '');
|
||||
}
|
||||
|
||||
function resolveSettingsRootPath(publicPath, modernPrefix, pathname) {
|
||||
const normalizedPublicPath = normalizePublicPath(publicPath);
|
||||
const normalizedModernPrefix =
|
||||
String(modernPrefix || 'v')
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, '') || 'v';
|
||||
const modernPublicPathSuffix = `/${normalizedModernPrefix}/`;
|
||||
if (!normalizedPublicPath.endsWith(modernPublicPathSuffix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modernRootPath = normalizePathname(normalizedPublicPath);
|
||||
const normalizedPathname = normalizePathname(pathname);
|
||||
let relativePath = '';
|
||||
if (normalizedPathname !== modernRootPath) {
|
||||
if (!normalizedPathname.startsWith(`${modernRootPath}/`)) {
|
||||
return null;
|
||||
}
|
||||
relativePath = normalizedPathname.slice(modernRootPath.length + 1);
|
||||
}
|
||||
|
||||
let appScope = '';
|
||||
if (relativePath) {
|
||||
const match = /^(apps|_app)\/([^/]+)$/.exec(relativePath);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
appScope = `/${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
const rootPublicPath = normalizedPublicPath.slice(0, -(normalizedModernPrefix.length + 1));
|
||||
const rootPrefix = rootPublicPath === '/' ? '' : rootPublicPath.replace(/\/+$/g, '');
|
||||
return `${rootPrefix}/settings${appScope}`;
|
||||
}
|
||||
|
||||
function isSettingsBrowserCheckerScript(script) {
|
||||
const source = String((script && script.src) || '').split(/[?#]/)[0];
|
||||
return source.endsWith('/settings/browser-checker.js');
|
||||
}
|
||||
|
||||
function resolveScopedSettingsRootPath(publicPath, pathname) {
|
||||
const normalizedPublicPath = normalizePublicPath(publicPath);
|
||||
const rootPrefix = normalizedPublicPath === '/' ? '' : normalizedPublicPath.replace(/\/+$/g, '');
|
||||
const settingsRootPath = `${rootPrefix}/settings`;
|
||||
if (!pathname.startsWith(`${settingsRootPath}/`)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativePath = pathname.slice(settingsRootPath.length + 1);
|
||||
if (!/^(apps|_app)\/[^/]+$/.test(relativePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${pathname}/`;
|
||||
}
|
||||
|
||||
const basename = normalizePublicPath(window['__nocobase_public_path__'] || '/');
|
||||
const currentPath = ensureLeadingSlash(String(window.location.pathname || '/').trim() || '/').replace(/\/{2,}/g, '/');
|
||||
const basenameWithoutTrailingSlash = basename === '/' ? '/' : basename.replace(/\/+$/, '');
|
||||
const settingsRootPath = resolveSettingsRootPath(basename, window['__nocobase_modern_client_prefix__'], currentPath);
|
||||
const scopedSettingsRootPath = isSettingsBrowserCheckerScript(document.currentScript)
|
||||
? resolveScopedSettingsRootPath(basename, currentPath)
|
||||
: null;
|
||||
|
||||
if (basename !== '/' && currentPath === basenameWithoutTrailingSlash) {
|
||||
if (settingsRootPath) {
|
||||
const newUrl = `${window.location.origin}${settingsRootPath}${window.location.search}${window.location.hash}`;
|
||||
window.location.replace(newUrl);
|
||||
} else if (scopedSettingsRootPath) {
|
||||
const newUrl = `${window.location.origin}${scopedSettingsRootPath}${window.location.search}${window.location.hash}`;
|
||||
window.location.replace(newUrl);
|
||||
} else if (basename !== '/' && currentPath === basenameWithoutTrailingSlash) {
|
||||
const newUrl = `${window.location.origin}${basename}${window.location.search}${window.location.hash}`;
|
||||
window.location.replace(newUrl);
|
||||
} else if (basename !== '/' && !currentPath.startsWith(basename)) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill';
|
||||
import { pluginReact } from '@rsbuild/plugin-react';
|
||||
import { pluginSvgr } from '@rsbuild/plugin-svgr';
|
||||
import { generateV2Plugins, getRsbuildBrowserAlias } from '@nocobase/devtools/rsbuildConfig';
|
||||
import { createSettingsDevProxyOptions, isSettingsDevPath } from '../settingsDevProxy';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -33,7 +34,11 @@ function normalizeModernClientPrefix(value: string | undefined) {
|
||||
const segment = String(value || '')
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
return segment || MODERN_CLIENT_DIST_DIR;
|
||||
const normalized = segment || MODERN_CLIENT_DIST_DIR;
|
||||
if (normalized === 'settings') {
|
||||
throw new Error('APP_MODERN_CLIENT_PREFIX "settings" is reserved for the standalone Settings application.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function ensurePublicPath(value: string) {
|
||||
@@ -159,6 +164,7 @@ export default defineConfig(({ command }) => {
|
||||
const wsBasePath = ensurePublicPath(process.env.WS_PATH || '/ws/');
|
||||
const hmrPath = `${v2PublicPath.replace(/\/$/, '')}/__rspack_hmr`;
|
||||
const v2Port = toNumber(process.env.APP_V2_PORT, 13002);
|
||||
const settingsPort = toNumber(process.env.APP_SETTINGS_PORT, toNumber(process.env.APP_PORT, 13001) + 3);
|
||||
const hmrClientHost = process.env.RSPACK_HMR_CLIENT_HOST;
|
||||
const hmrClientPort = toNumber(process.env.RSPACK_HMR_CLIENT_PORT || process.env.APP_PORT, v2Port);
|
||||
const proxyTargetUrl = process.env.PROXY_TARGET_URL || `http://127.0.0.1:${process.env.APP_PORT || 13001}`;
|
||||
@@ -257,8 +263,10 @@ export default defineConfig(({ command }) => {
|
||||
publicDir: {
|
||||
name: path.resolve(__dirname, 'public'),
|
||||
},
|
||||
proxy: {
|
||||
[apiBasePath]: {
|
||||
proxy: [
|
||||
createSettingsDevProxyOptions(appPublicPath, settingsPort),
|
||||
{
|
||||
context: apiBasePath,
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
@@ -276,31 +284,36 @@ export default defineConfig(({ command }) => {
|
||||
}
|
||||
},
|
||||
},
|
||||
[localStorageBasePath]: {
|
||||
{
|
||||
context: localStorageBasePath,
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
[fileBasePath]: {
|
||||
{
|
||||
context: fileBasePath,
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
[staticBasePath]: {
|
||||
{
|
||||
context: staticBasePath,
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
[portalBasePath]: {
|
||||
{
|
||||
context: portalBasePath,
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
xfwd: true,
|
||||
},
|
||||
[wsBasePath]: {
|
||||
{
|
||||
context: wsBasePath,
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
xfwd: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
historyApiFallback: {
|
||||
disableDotRule: true,
|
||||
index: `${v2PublicPath}index.html`,
|
||||
@@ -325,7 +338,8 @@ export default defineConfig(({ command }) => {
|
||||
pathname.startsWith(wsBasePath) ||
|
||||
pathname.startsWith(localStorageBasePath) ||
|
||||
pathname.startsWith(portalBasePath) ||
|
||||
pathname.startsWith(staticBasePath)
|
||||
pathname.startsWith(staticBasePath) ||
|
||||
isSettingsDevPath(pathname, appPublicPath)
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill';
|
||||
import { pluginReact } from '@rsbuild/plugin-react';
|
||||
import { pluginSvgr } from '@rsbuild/plugin-svgr';
|
||||
import { generatePlugins, getRsbuildBrowserAlias } from '@nocobase/devtools/rsbuildConfig';
|
||||
import { createSettingsDevProxyOptions } from '../settingsDevProxy';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -46,11 +47,19 @@ function toDefineLiteral(value: string | undefined) {
|
||||
return value === undefined ? 'undefined' : JSON.stringify(value);
|
||||
}
|
||||
|
||||
function createRuntimeHeadScript(appPublicPath: string, isBuild: boolean) {
|
||||
const modernClientPrefix =
|
||||
String(process.env.APP_MODERN_CLIENT_PREFIX || 'v')
|
||||
function normalizeModernClientPrefix(value: string | undefined) {
|
||||
const normalized =
|
||||
String(value || 'v')
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, '') || 'v';
|
||||
if (normalized === 'settings') {
|
||||
throw new Error('APP_MODERN_CLIENT_PREFIX "settings" is reserved for the standalone Settings application.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function createRuntimeHeadScript(appPublicPath: string, isBuild: boolean) {
|
||||
const modernClientPrefix = normalizeModernClientPrefix(process.env.APP_MODERN_CLIENT_PREFIX);
|
||||
const appClientEntryMode = process.env.APP_CLIENT_ENTRY_MODE;
|
||||
if (!isBuild) {
|
||||
return [
|
||||
@@ -109,10 +118,7 @@ export default defineConfig(({ command }) => {
|
||||
const localStorageBasePath = ensurePublicPath(`${resolvedAppPublicPath}storage/uploads/`, '/storage/uploads/');
|
||||
const staticBasePath = ensurePublicPath(`${resolvedAppPublicPath}static/`, '/static/');
|
||||
const wsBasePath = ensurePublicPath(process.env.WS_PATH, '/ws/');
|
||||
const modernClientPrefix =
|
||||
String(process.env.APP_MODERN_CLIENT_PREFIX || 'v')
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, '') || 'v';
|
||||
const modernClientPrefix = normalizeModernClientPrefix(process.env.APP_MODERN_CLIENT_PREFIX);
|
||||
const v2BasePath = ensurePublicPath(
|
||||
`${resolvedAppPublicPath.replace(/\/$/, '')}/${modernClientPrefix}/`,
|
||||
`/${modernClientPrefix}/`,
|
||||
@@ -120,6 +126,7 @@ export default defineConfig(({ command }) => {
|
||||
const portalBasePath = ensurePublicPath(`${resolvedAppPublicPath.replace(/\/$/, '')}/x/`, '/x/');
|
||||
const clientPort = toNumber(process.env.APP_PORT, 13001);
|
||||
const v2Port = toNumber(process.env.APP_V2_PORT, clientPort + 2);
|
||||
const settingsPort = toNumber(process.env.APP_SETTINGS_PORT, clientPort + 3);
|
||||
const hmrPath = `${resolvedAppPublicPath.replace(/\/$/, '')}/__rspack_hmr`;
|
||||
const proxyTargetUrl = process.env.PROXY_TARGET_URL || `http://127.0.0.1:${clientPort + 1}`;
|
||||
const hmrClientHost = process.env.RSPACK_HMR_CLIENT_HOST;
|
||||
@@ -208,8 +215,10 @@ export default defineConfig(({ command }) => {
|
||||
publicDir: {
|
||||
name: path.resolve(__dirname, 'public'),
|
||||
},
|
||||
proxy: {
|
||||
[apiBasePath]: {
|
||||
proxy: [
|
||||
createSettingsDevProxyOptions(resolvedAppPublicPath, settingsPort),
|
||||
{
|
||||
context: apiBasePath,
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
@@ -227,25 +236,30 @@ export default defineConfig(({ command }) => {
|
||||
}
|
||||
},
|
||||
},
|
||||
[localStorageBasePath]: {
|
||||
{
|
||||
context: localStorageBasePath,
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
[fileBasePath]: {
|
||||
{
|
||||
context: fileBasePath,
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
[staticBasePath]: {
|
||||
{
|
||||
context: staticBasePath,
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
[wsBasePath]: {
|
||||
{
|
||||
context: wsBasePath,
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
xfwd: true,
|
||||
},
|
||||
[v2BasePath]: {
|
||||
{
|
||||
context: v2BasePath,
|
||||
target: `http://127.0.0.1:${v2Port}`,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
@@ -254,13 +268,14 @@ export default defineConfig(({ command }) => {
|
||||
},
|
||||
xfwd: true,
|
||||
},
|
||||
[portalBasePath]: {
|
||||
{
|
||||
context: portalBasePath,
|
||||
target: proxyTargetUrl,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
xfwd: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
historyApiFallback: {
|
||||
disableDotRule: true,
|
||||
index: `${resolvedAppPublicPath}index.html`,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
function normalizePublicPath(value: string) {
|
||||
let normalized = value || '/';
|
||||
if (!normalized.startsWith('/')) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
if (!normalized.endsWith('/')) {
|
||||
normalized = `${normalized}/`;
|
||||
}
|
||||
return normalized.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function createSettingsPathPattern(appPublicPath: string) {
|
||||
const publicPath = normalizePublicPath(appPublicPath);
|
||||
return new RegExp(`^${escapeRegExp(publicPath)}settings(?:/|$)`);
|
||||
}
|
||||
|
||||
export function isSettingsDevPath(url: string, appPublicPath: string) {
|
||||
const [pathname] = String(url || '/').split(/[?#]/, 1);
|
||||
return createSettingsPathPattern(appPublicPath).test(pathname);
|
||||
}
|
||||
|
||||
export function rewriteSettingsDevProxyPath(url: string, appPublicPath: string) {
|
||||
const publicPath = normalizePublicPath(appPublicPath);
|
||||
const settingsRoot = `${publicPath}settings`;
|
||||
const settingsRootWithoutTrailingSlash = new RegExp(`^${escapeRegExp(settingsRoot)}(?=[?#]|$)`);
|
||||
|
||||
return url.replace(settingsRootWithoutTrailingSlash, `${settingsRoot}/`);
|
||||
}
|
||||
|
||||
export function createSettingsDevProxyOptions(appPublicPath: string, settingsPort: number) {
|
||||
return {
|
||||
context: (pathname: string) => isSettingsDevPath(pathname, appPublicPath),
|
||||
target: `http://127.0.0.1:${settingsPort}`,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
xfwd: true,
|
||||
pathRewrite: (pathname: string) => rewriteSettingsDevProxyPath(pathname, appPublicPath),
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Client Settings build stage', () => {
|
||||
it('builds Settings with its own Rsbuild stage', () => {
|
||||
const source = fs.readFileSync(path.resolve(__dirname, '../build.ts'), 'utf8');
|
||||
|
||||
expect(source).toContain("'app client-settings shell'");
|
||||
expect(source).toContain("path.join(CORE_APP, 'client-settings', 'rsbuild.config.ts')");
|
||||
expect(source.indexOf("'app client-settings shell'")).toBeGreaterThan(source.indexOf("'app client-v2 shell'"));
|
||||
|
||||
const settingsConfig = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../../app/client-settings/rsbuild.config.ts'),
|
||||
'utf8',
|
||||
);
|
||||
expect(settingsConfig).toContain("window['__nocobase_modern_client_prefix__']");
|
||||
});
|
||||
});
|
||||
@@ -145,10 +145,14 @@ export async function build(pkgs: string[]) {
|
||||
const appClient = packages.find((item) => item.location === CORE_APP);
|
||||
if (appClient) {
|
||||
await runProfiledStage(profile, 'app client shell', async () => {
|
||||
await runScript(['rsbuild', 'build', '--config', path.join(CORE_APP, 'client', 'rsbuild.config.ts')], ROOT_PATH, {
|
||||
APP_ROOT: path.join(CORE_APP, 'client'),
|
||||
ANALYZE: process.env.BUILD_ANALYZE === 'true' ? '1' : undefined,
|
||||
});
|
||||
await runScript(
|
||||
['rsbuild', 'build', '--config', path.join(CORE_APP, 'client', 'rsbuild.config.ts')],
|
||||
ROOT_PATH,
|
||||
{
|
||||
APP_ROOT: path.join(CORE_APP, 'client'),
|
||||
ANALYZE: process.env.BUILD_ANALYZE === 'true' ? '1' : undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
await runProfiledStage(profile, 'app client-v2 shell', async () => {
|
||||
await runScript(
|
||||
@@ -159,6 +163,15 @@ export async function build(pkgs: string[]) {
|
||||
},
|
||||
);
|
||||
});
|
||||
await runProfiledStage(profile, 'app client-settings shell', async () => {
|
||||
await runScript(
|
||||
['rsbuild', 'build', '--config', path.join(CORE_APP, 'client-settings', 'rsbuild.config.ts')],
|
||||
ROOT_PATH,
|
||||
{
|
||||
ANALYZE: process.env.BUILD_ANALYZE === 'true' ? '1' : undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
writeToCache(BUILD_ERROR, {});
|
||||
} finally {
|
||||
@@ -191,7 +204,11 @@ export async function buildPackages(
|
||||
await runProfiledStage(profile, `${stageName} source`, async () => {
|
||||
for (let index = 0; index < layers.length; index++) {
|
||||
const layer = layers[index];
|
||||
console.log(chalk.cyan(`[@nocobase/build]: ${stageName} source layer ${index + 1}/${layers.length} (${layer.length} packages)`));
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
`[@nocobase/build]: ${stageName} source layer ${index + 1}/${layers.length} (${layer.length} packages)`,
|
||||
),
|
||||
);
|
||||
const layerStart = nowMs();
|
||||
await runWithConcurrency(layer, sourceConcurrency, async (pkg) => {
|
||||
await buildPackageSourceLifecycle(pkg, targetDir, doBuildPackage, profile);
|
||||
@@ -207,7 +224,13 @@ export async function buildPackages(
|
||||
});
|
||||
}
|
||||
if (ENABLE_BUILD_PROFILE) {
|
||||
console.log(chalk.gray(`[@nocobase/build]: ${stageName} source layer ${index + 1}/${layers.length} finished in ${formatDuration(layerDurationMs)}`));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`[@nocobase/build]: ${stageName} source layer ${index + 1}/${layers.length} finished in ${formatDuration(
|
||||
layerDurationMs,
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -219,7 +242,11 @@ export async function buildPackages(
|
||||
await runProfiledStage(profile, `${stageName} declaration`, async () => {
|
||||
for (let index = 0; index < layers.length; index++) {
|
||||
const layer = layers[index];
|
||||
console.log(chalk.cyan(`[@nocobase/build]: ${stageName} declaration layer ${index + 1}/${layers.length} (${layer.length} packages)`));
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
`[@nocobase/build]: ${stageName} declaration layer ${index + 1}/${layers.length} (${layer.length} packages)`,
|
||||
),
|
||||
);
|
||||
const layerStart = nowMs();
|
||||
await runWithConcurrency(layer, declarationConcurrency, async (pkg) => {
|
||||
await buildPackageDeclarationLifecycle(pkg, targetDir, profile);
|
||||
@@ -235,7 +262,13 @@ export async function buildPackages(
|
||||
});
|
||||
}
|
||||
if (ENABLE_BUILD_PROFILE) {
|
||||
console.log(chalk.gray(`[@nocobase/build]: ${stageName} declaration layer ${index + 1}/${layers.length} finished in ${formatDuration(layerDurationMs)}`));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`[@nocobase/build]: ${stageName} declaration layer ${index + 1}/${
|
||||
layers.length
|
||||
} finished in ${formatDuration(layerDurationMs)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -373,7 +406,9 @@ async function buildPackageSourceLifecycle(
|
||||
.join(', ');
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`[@nocobase/build:profile] ${pkg.name} ${status} in ${formatDuration(nowMs() - packageStart)}${summary ? ` (${summary})` : ''}`,
|
||||
`[@nocobase/build:profile] ${pkg.name} ${status} in ${formatDuration(nowMs() - packageStart)}${
|
||||
summary ? ` (${summary})` : ''
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -417,7 +452,9 @@ async function buildPackageDeclarationLifecycle(
|
||||
.join(', ');
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`[@nocobase/build:profile] ${pkg.name} declaration ${status} in ${formatDuration(nowMs() - packageStart)}${summary ? ` (${summary})` : ''}`,
|
||||
`[@nocobase/build:profile] ${pkg.name} declaration ${status} in ${formatDuration(nowMs() - packageStart)}${
|
||||
summary ? ` (${summary})` : ''
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,6 +128,34 @@ server {
|
||||
send_timeout 600;
|
||||
}
|
||||
|
||||
location ^~ {{settingsAssetsPath}} {
|
||||
alias {{cwd}}/node_modules/@nocobase/app/dist/client/settings/assets/;
|
||||
expires 365d;
|
||||
add_header Cache-Control "public";
|
||||
access_log off;
|
||||
autoindex off;
|
||||
}
|
||||
|
||||
# The standalone Settings SPA is a Client V2 surface. Keep this matcher
|
||||
# narrow so legacy /admin/settings routes continue to use the v1 HTML.
|
||||
location ~ {{settingsDocumentPattern}} {
|
||||
proxy_pass http://127.0.0.1:{{apiPort}};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $upstream_x_forwarded_proto;
|
||||
proxy_set_header Host $final_host;
|
||||
proxy_set_header Referer $http_referer;
|
||||
proxy_set_header User-Agent $http_user_agent;
|
||||
add_header Cache-Control 'no-cache, no-store';
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_connect_timeout 600;
|
||||
proxy_send_timeout 600;
|
||||
proxy_read_timeout 600;
|
||||
send_timeout 600;
|
||||
}
|
||||
|
||||
# RFC 8414 root-mounted discovery compatibility for path-based issuers/resources.
|
||||
location ~ ^/\.well-known/oauth-authorization-server/(.+)$ {
|
||||
rewrite ^/\.well-known/oauth-authorization-server/(.+)$ /$1/.well-known/oauth-authorization-server break;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/* eslint-env jest */
|
||||
|
||||
const fs = require('fs-extra');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const registerCreateNginxConf = require('../commands/create-nginx-conf');
|
||||
|
||||
describe('create-nginx-conf Settings SPA routing', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
let storagePath;
|
||||
|
||||
afterEach(async () => {
|
||||
process.env = { ...originalEnv };
|
||||
if (storagePath) {
|
||||
await fs.remove(storagePath);
|
||||
storagePath = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
async function renderConfig(appPublicPath) {
|
||||
storagePath = await fs.mkdtemp(path.join(os.tmpdir(), 'nocobase-nginx-settings-'));
|
||||
process.env.APP_PUBLIC_PATH = appPublicPath;
|
||||
process.env.APP_MODERN_CLIENT_PREFIX = 'v';
|
||||
process.env.APP_PORT = '13000';
|
||||
process.env.STORAGE_PATH = storagePath;
|
||||
|
||||
let action;
|
||||
registerCreateNginxConf({
|
||||
command() {
|
||||
return {
|
||||
action(callback) {
|
||||
action = callback;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
await action();
|
||||
return await fs.readFile(path.join(storagePath, 'nocobase.conf'), 'utf8');
|
||||
}
|
||||
|
||||
test.each([
|
||||
['root mount', '/', '/settings/assets/', '^/settings(?:/|$)'],
|
||||
['custom public path', '/nocobase/', '/nocobase/settings/assets/', '^/nocobase/settings(?:/|$)'],
|
||||
])(
|
||||
'proxies Settings documents and caches Settings assets for %s',
|
||||
async (_label, publicPath, assetsPath, routePattern) => {
|
||||
const config = await renderConfig(publicPath);
|
||||
|
||||
expect(config).toContain(`location ^~ ${assetsPath} {`);
|
||||
expect(config).toContain('dist/client/settings/assets/;');
|
||||
expect(config).toContain('expires 365d;');
|
||||
expect(config).toContain(`location ~ ${routePattern} {`);
|
||||
expect(config).toContain('proxy_pass http://127.0.0.1:13000;');
|
||||
expect(config).not.toContain('location ~ ^/admin/settings');
|
||||
expect(config).toContain('try_files $uri $uri/ /index.html;');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -9,7 +9,13 @@
|
||||
|
||||
/* eslint-env jest */
|
||||
|
||||
const { buildAppDevForwardArgs, forwardDevToAppDev, resolveDevRuntimeMode } = require('../commands/dev')._test;
|
||||
const {
|
||||
buildAppDevForwardArgs,
|
||||
createSettingsDevProcessOptions,
|
||||
forwardDevToAppDev,
|
||||
resolveDevRuntimeMode,
|
||||
resolveSettingsDevPort,
|
||||
} = require('../commands/dev')._test;
|
||||
|
||||
describe('cli-v1 dev command', () => {
|
||||
test('buildAppDevForwardArgs rewrites dev argv to app-dev while preserving extra args', () => {
|
||||
@@ -46,6 +52,7 @@ describe('cli-v1 dev command', () => {
|
||||
useModernOnlyEntryMode: false,
|
||||
shouldRunClient: true,
|
||||
shouldRunClientV2: true,
|
||||
shouldRunSettings: true,
|
||||
shouldRunServer: true,
|
||||
});
|
||||
});
|
||||
@@ -55,6 +62,7 @@ describe('cli-v1 dev command', () => {
|
||||
useModernOnlyEntryMode: true,
|
||||
shouldRunClient: false,
|
||||
shouldRunClientV2: true,
|
||||
shouldRunSettings: true,
|
||||
shouldRunServer: true,
|
||||
});
|
||||
});
|
||||
@@ -64,7 +72,37 @@ describe('cli-v1 dev command', () => {
|
||||
useModernOnlyEntryMode: false,
|
||||
shouldRunClient: false,
|
||||
shouldRunClientV2: true,
|
||||
shouldRunSettings: true,
|
||||
shouldRunServer: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveSettingsDevPort reserves APP_PORT + 3 by default', () => {
|
||||
expect(resolveSettingsDevPort(13001)).toBe(13004);
|
||||
});
|
||||
|
||||
test('createSettingsDevProcessOptions uses the standalone config and settings HMR path', () => {
|
||||
expect(
|
||||
createSettingsDevProcessOptions({
|
||||
appPackageRoot: '/repo/packages/core/app',
|
||||
appPort: 13001,
|
||||
settingsPort: 13004,
|
||||
browserPort: 13001,
|
||||
appPublicPath: '/nocobase/',
|
||||
processEnv: { API_BASE_URL: '/api/' },
|
||||
}),
|
||||
).toMatchObject({
|
||||
command: 'rsbuild',
|
||||
args: ['dev', '--config', '/repo/packages/core/app/client-settings/rsbuild.config.ts'],
|
||||
runOptions: {
|
||||
prefix: 'client-settings',
|
||||
env: {
|
||||
APP_PORT: '13001',
|
||||
APP_SETTINGS_PORT: '13004',
|
||||
RSPACK_HMR_CLIENT_PORT: '13001',
|
||||
RSPACK_HMR_PATH: '/nocobase/settings/__rspack_hmr',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,9 +9,14 @@
|
||||
|
||||
/* eslint-env jest */
|
||||
|
||||
const { normalizeModernClientPrefix } = require('../util');
|
||||
const { colorizedDevLogEnv, createRunWithPrefixLabel } = require('../util')._test;
|
||||
|
||||
describe('cli-v1 util helpers', () => {
|
||||
test('normalizeModernClientPrefix reserves the Settings SPA path', () => {
|
||||
expect(() => normalizeModernClientPrefix('/settings/')).toThrow('APP_MODERN_CLIENT_PREFIX "settings" is reserved');
|
||||
});
|
||||
|
||||
test('colorizedDevLogEnv enables color for dev child output by default', () => {
|
||||
expect(colorizedDevLogEnv({})).toEqual({ FORCE_COLOR: '1' });
|
||||
});
|
||||
|
||||
@@ -11,6 +11,10 @@ const { resolve, posix } = require('path');
|
||||
const { storagePathJoin, resolvePublicPath, resolveV2PublicPath, normalizeModernClientPrefix } = require('../util');
|
||||
const { readFileSync, writeFileSync } = require('fs');
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Command} cli
|
||||
@@ -24,6 +28,8 @@ module.exports = (cli) => {
|
||||
const modernClientPrefix = normalizeModernClientPrefix(process.env.APP_MODERN_CLIENT_PREFIX);
|
||||
const appPublicPathWithoutTrailingSlash = appPublicPath.replace(/\/$/, '');
|
||||
const v2PublicPathWithoutTrailingSlash = v2PublicPath.replace(/\/$/, '');
|
||||
const settingsAssetsPath = `${appPublicPath}settings/assets/`;
|
||||
const settingsDocumentPattern = `^${escapeRegExp(appPublicPath)}settings(?:/|$)`;
|
||||
const file = resolve(__dirname, '../../nocobase.conf.tpl');
|
||||
const data = readFileSync(file, 'utf-8');
|
||||
let otherLocation = '';
|
||||
@@ -64,6 +70,8 @@ module.exports = (cli) => {
|
||||
.replace(/\{\{distPath\}\}/g, distPath)
|
||||
.replace(/\{\{v2PublicPath\}\}/g, v2PublicPath)
|
||||
.replace(/\{\{v2PublicPathNoTrailingSlash\}\}/g, v2PublicPathWithoutTrailingSlash)
|
||||
.replace(/\{\{settingsAssetsPath\}\}/g, settingsAssetsPath)
|
||||
.replace(/\{\{settingsDocumentPattern\}\}/g, settingsDocumentPattern)
|
||||
.replace(/\{\{apiPort\}\}/g, process.env.APP_PORT)
|
||||
.replace(/\{\{otherLocation\}\}/g, otherLocation);
|
||||
const targetFile = storagePathJoin('nocobase.conf');
|
||||
|
||||
@@ -48,6 +48,48 @@ function buildAppDevForwardArgs(argv = process.argv) {
|
||||
return ['app-dev', ...argv.slice(3)];
|
||||
}
|
||||
|
||||
function resolveSettingsDevPort(appPort) {
|
||||
return Number(appPort) + 3;
|
||||
}
|
||||
|
||||
function normalizePublicPath(value) {
|
||||
let normalized = value || '/';
|
||||
if (!normalized.startsWith('/')) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
if (!normalized.endsWith('/')) {
|
||||
normalized = `${normalized}/`;
|
||||
}
|
||||
return normalized.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
function createSettingsDevProcessOptions({
|
||||
appPackageRoot,
|
||||
appPort,
|
||||
settingsPort,
|
||||
browserPort,
|
||||
appPublicPath,
|
||||
processEnv = process.env,
|
||||
}) {
|
||||
const settingsHmrPath = `${normalizePublicPath(appPublicPath)}settings/__rspack_hmr`;
|
||||
return {
|
||||
command: 'rsbuild',
|
||||
args: ['dev', '--config', `${appPackageRoot}/client-settings/rsbuild.config.ts`],
|
||||
runOptions: {
|
||||
prefix: 'client-settings',
|
||||
color: 'yellow',
|
||||
env: {
|
||||
...processEnv,
|
||||
APP_PORT: `${appPort}`,
|
||||
APP_SETTINGS_PORT: `${settingsPort}`,
|
||||
NODE_ENV: 'development',
|
||||
RSPACK_HMR_CLIENT_PORT: `${browserPort}`,
|
||||
RSPACK_HMR_PATH: settingsHmrPath,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDevRuntimeMode(opts = {}) {
|
||||
const appClientEntryMode = opts.appClientEntryMode || resolveAppClientEntryMode();
|
||||
const useModernOnlyEntryMode = appClientEntryMode === 'modern-only';
|
||||
@@ -57,6 +99,7 @@ function resolveDevRuntimeMode(opts = {}) {
|
||||
const shouldRunClientV2 = clientV2Only || useModernOnlyEntryMode || forceClient || !forceServer;
|
||||
const shouldRunClient = !clientV2Only && !useModernOnlyEntryMode && (forceClient || !forceServer);
|
||||
const shouldRunServer = !clientV2Only && (forceServer || !forceClient || useModernOnlyEntryMode);
|
||||
const shouldRunSettings = shouldRunClientV2;
|
||||
|
||||
return {
|
||||
appClientEntryMode,
|
||||
@@ -64,6 +107,7 @@ function resolveDevRuntimeMode(opts = {}) {
|
||||
shouldRunClientV2,
|
||||
shouldRunClient,
|
||||
shouldRunServer,
|
||||
shouldRunSettings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,13 +168,18 @@ module.exports = (cli) => {
|
||||
let clientPort = APP_PORT;
|
||||
let serverPort;
|
||||
let clientV2Port = APP_PORT;
|
||||
let settingsPort = resolveSettingsDevPort(APP_PORT);
|
||||
|
||||
nodeCheck();
|
||||
await postCheck(opts);
|
||||
|
||||
const { useModernOnlyEntryMode, shouldRunClientV2, shouldRunClient, shouldRunServer } = resolveDevRuntimeMode(
|
||||
opts,
|
||||
);
|
||||
const {
|
||||
useModernOnlyEntryMode,
|
||||
shouldRunClientV2,
|
||||
shouldRunClient,
|
||||
shouldRunServer,
|
||||
shouldRunSettings,
|
||||
} = resolveDevRuntimeMode(opts);
|
||||
const shouldRunClientWithRsbuild = shouldRunClient && !!rsbuild;
|
||||
|
||||
if (shouldRunServer && server) {
|
||||
@@ -151,8 +200,15 @@ module.exports = (cli) => {
|
||||
clientV2Port = APP_PORT;
|
||||
}
|
||||
|
||||
if (shouldRunSettings) {
|
||||
settingsPort = await getPortPromise({
|
||||
port: resolveSettingsDevPort(APP_PORT),
|
||||
});
|
||||
}
|
||||
|
||||
let subprocessClient;
|
||||
let subprocessClientV2;
|
||||
let subprocessSettings;
|
||||
|
||||
const runDevClientV2 = () => {
|
||||
console.log('starting client-v2', 1 * clientV2Port);
|
||||
@@ -165,6 +221,7 @@ module.exports = (cli) => {
|
||||
env: {
|
||||
...process.env,
|
||||
APP_V2_PORT: `${clientV2Port}`,
|
||||
APP_SETTINGS_PORT: `${settingsPort}`,
|
||||
NODE_ENV: 'development',
|
||||
RSPACK_HMR_CLIENT_PORT: `${clientV2Only ? clientV2Port : clientPort}`,
|
||||
API_BASE_URL: process.env.API_BASE_URL || process.env.API_BASE_PATH,
|
||||
@@ -182,8 +239,34 @@ module.exports = (cli) => {
|
||||
);
|
||||
};
|
||||
|
||||
const runDevSettings = () => {
|
||||
console.log('starting client-settings', 1 * settingsPort);
|
||||
const { command, args, runOptions } = createSettingsDevProcessOptions({
|
||||
appPackageRoot: APP_PACKAGE_ROOT,
|
||||
appPort: APP_PORT,
|
||||
settingsPort,
|
||||
browserPort: clientV2Only ? clientV2Port : clientPort,
|
||||
appPublicPath: process.env.APP_PUBLIC_PATH,
|
||||
processEnv: {
|
||||
...process.env,
|
||||
API_BASE_URL: process.env.API_BASE_URL || process.env.API_BASE_PATH,
|
||||
API_CLIENT_STORAGE_PREFIX: process.env.API_CLIENT_STORAGE_PREFIX,
|
||||
API_CLIENT_STORAGE_TYPE: process.env.API_CLIENT_STORAGE_TYPE,
|
||||
API_CLIENT_SHARE_TOKEN: process.env.API_CLIENT_SHARE_TOKEN || 'false',
|
||||
WEBSOCKET_URL: process.env.WEBSOCKET_URL || buildWSURL(process.env.API_BASE_URL, serverPort),
|
||||
WS_PATH: process.env.WS_PATH,
|
||||
ESM_CDN_BASE_URL: process.env.ESM_CDN_BASE_URL || 'https://esm.sh',
|
||||
ESM_CDN_SUFFIX: process.env.ESM_CDN_SUFFIX || '',
|
||||
PROXY_TARGET_URL:
|
||||
process.env.PROXY_TARGET_URL || (serverPort ? `http://127.0.0.1:${serverPort}` : undefined),
|
||||
},
|
||||
});
|
||||
subprocessSettings = runWithPrefix(command, args, runOptions);
|
||||
};
|
||||
|
||||
if (clientV2Only) {
|
||||
runDevClientV2();
|
||||
runDevSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -204,6 +287,7 @@ module.exports = (cli) => {
|
||||
APP_PORT: `${clientPort}`,
|
||||
APP_ROOT: `${APP_PACKAGE_ROOT}/client`,
|
||||
APP_V2_PORT: `${clientV2Port}`,
|
||||
APP_SETTINGS_PORT: `${settingsPort}`,
|
||||
NODE_ENV: 'development',
|
||||
RSPACK_HMR_CLIENT_PORT: `${clientPort}`,
|
||||
API_BASE_URL: process.env.API_BASE_URL || process.env.API_BASE_PATH,
|
||||
@@ -256,6 +340,9 @@ module.exports = (cli) => {
|
||||
if (shouldRunClientV2) {
|
||||
await restartSubprocess(subprocessClientV2, clientV2Port, runDevClientV2);
|
||||
}
|
||||
if (shouldRunSettings) {
|
||||
await restartSubprocess(subprocessSettings, settingsPort, runDevSettings);
|
||||
}
|
||||
await fs.promises.writeFile(process.env.WATCH_FILE, `export const watchId = '${uid()}';`, 'utf-8');
|
||||
}, 500);
|
||||
|
||||
@@ -325,11 +412,17 @@ module.exports = (cli) => {
|
||||
if (shouldRunClientV2) {
|
||||
runDevClientV2();
|
||||
}
|
||||
|
||||
if (shouldRunSettings) {
|
||||
runDevSettings();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
module.exports._test = {
|
||||
buildAppDevForwardArgs,
|
||||
createSettingsDevProcessOptions,
|
||||
forwardDevToAppDev,
|
||||
resolveDevRuntimeMode,
|
||||
resolveSettingsDevPort,
|
||||
};
|
||||
|
||||
@@ -462,7 +462,11 @@ function normalizeModernClientPrefix(value) {
|
||||
const segment = String(value || '')
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
return segment || DEFAULT_MODERN_CLIENT_PREFIX;
|
||||
const normalized = segment || DEFAULT_MODERN_CLIENT_PREFIX;
|
||||
if (normalized === 'settings') {
|
||||
throw new Error('APP_MODERN_CLIENT_PREFIX "settings" is reserved for the standalone Settings application.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
exports.normalizeModernClientPrefix = normalizeModernClientPrefix;
|
||||
|
||||
@@ -556,7 +556,7 @@ test('start injects init env vars for prepared local envs and marks them install
|
||||
rootPassword: 'admin123',
|
||||
rootNickname: 'Admin',
|
||||
portalType: 'ai',
|
||||
portalName: 'admin',
|
||||
portalName: 'main',
|
||||
portalTemplate: '/tmp/portal-template',
|
||||
},
|
||||
envVars: { APP_PORT: '13000' },
|
||||
@@ -580,7 +580,7 @@ test('start injects init env vars for prepared local envs and marks them install
|
||||
INIT_ROOT_PASSWORD: 'admin123',
|
||||
INIT_ROOT_NICKNAME: 'Admin',
|
||||
INIT_PORTAL_TYPE: 'ai',
|
||||
INIT_PORTAL_NAME: 'admin',
|
||||
INIT_PORTAL_NAME: 'main',
|
||||
INIT_PORTAL_TEMPLATE: '/tmp/portal-template',
|
||||
},
|
||||
stdio: 'ignore',
|
||||
@@ -5945,8 +5945,8 @@ test('dev runs local npm/git source envs with a generated port when --port is om
|
||||
|
||||
await Dev.prototype.run.call(command);
|
||||
|
||||
expect(mocks.announceTargetEnv).toHaveBeenCalledWith('dev');
|
||||
expect(mocks.printInfo.mock.calls).toEqual([
|
||||
['Using env "dev".'],
|
||||
['Starting NocoBase dev mode for "dev" from /tmp/nocobase. Press Ctrl+C to stop.'],
|
||||
]);
|
||||
expect(mocks.startTask.mock.calls).toEqual([['Running local postinstall for "dev"...']]);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { afterEach, expect, test } from 'vitest';
|
||||
import type { ManagedAppRuntime } from '../lib/app-runtime.js';
|
||||
import { setCliConfigValue } from '../lib/cli-config.js';
|
||||
import { writeNginxProxyBundle } from '../lib/proxy-nginx.js';
|
||||
import { writeCaddyProxyBundle } from '../lib/proxy-caddy.js';
|
||||
import {
|
||||
appConfigHasManagedNginxBlock,
|
||||
buildEnvProxyAppConfig,
|
||||
@@ -40,12 +41,18 @@ import {
|
||||
} from '../lib/env-proxy.ts';
|
||||
|
||||
const createdRoots: string[] = [];
|
||||
const originalModernClientPrefix = process.env.APP_MODERN_CLIENT_PREFIX;
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of createdRoots.splice(0)) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
delete process.env.NB_CLI_ROOT;
|
||||
if (originalModernClientPrefix === undefined) {
|
||||
delete process.env.APP_MODERN_CLIENT_PREFIX;
|
||||
} else {
|
||||
process.env.APP_MODERN_CLIENT_PREFIX = originalModernClientPrefix;
|
||||
}
|
||||
});
|
||||
|
||||
async function createTempRoot(prefix: string) {
|
||||
@@ -86,6 +93,7 @@ async function createLocalRuntime(
|
||||
|
||||
await mkdir(projectRoot, { recursive: true });
|
||||
await mkdir(path.join(versionRoot, 'v'), { recursive: true });
|
||||
await mkdir(path.join(versionRoot, 'settings'), { recursive: true });
|
||||
await writeFile(path.join(appPath, '.env'), envLines.join('\n'));
|
||||
await writeFile(path.join(distRoot, 'active-version'), version);
|
||||
await writeFile(
|
||||
@@ -119,8 +127,23 @@ async function createLocalRuntime(
|
||||
'</head><body></body></html>',
|
||||
].join(''),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(versionRoot, 'settings', 'index.html'),
|
||||
[
|
||||
'<!doctype html>',
|
||||
'<html><head>',
|
||||
`<script>window['__nocobase_public_path__'] = window['__nocobase_public_path__'] || "/settings/";`,
|
||||
`window['__nocobase_api_base_url__'] = window['__nocobase_api_base_url__'] || "${
|
||||
sourceV1PublicPath === '/' ? '/api/' : `${sourceV1PublicPath.replace(/\/$/, '')}/api/`
|
||||
}";</script>`,
|
||||
'<script src="/settings/browser-checker.js?v=1"></script>',
|
||||
'<script type="module" src="/settings/assets/runtime.js"></script>',
|
||||
'<link rel="stylesheet" href="/settings/assets/index.css">',
|
||||
'</head><body></body></html>',
|
||||
].join(''),
|
||||
);
|
||||
|
||||
return ({
|
||||
return {
|
||||
kind: 'local',
|
||||
envName: 'demo',
|
||||
source: 'npm',
|
||||
@@ -137,7 +160,7 @@ async function createLocalRuntime(
|
||||
version,
|
||||
},
|
||||
},
|
||||
} as unknown) as Extract<ManagedAppRuntime, { kind: 'local' }>;
|
||||
} as unknown as Extract<ManagedAppRuntime, { kind: 'local' }>;
|
||||
}
|
||||
|
||||
test('buildEnvProxyNginxBundle renders app.conf and index HTML with CDN-prefixed assets', async () => {
|
||||
@@ -160,6 +183,9 @@ test('buildEnvProxyNginxBundle renders app.conf and index HTML with CDN-prefixed
|
||||
expect(bundle.appConfigPath).toBe(path.join(root, '.nocobase', 'proxy', 'nginx', 'demo', 'app.conf'));
|
||||
expect(bundle.indexV1Path).toBe(path.join(root, '.nocobase', 'proxy', 'nginx', 'demo', 'public', 'index-v1.html'));
|
||||
expect(bundle.indexV2Path).toBe(path.join(root, '.nocobase', 'proxy', 'nginx', 'demo', 'public', 'index-v2.html'));
|
||||
expect(bundle.indexSettingsPath).toBe(
|
||||
path.join(root, '.nocobase', 'proxy', 'nginx', 'demo', 'public', 'index-settings.html'),
|
||||
);
|
||||
expect(bundle.cdnBaseUrl).toBe('/console/dist/2.1.0-beta.44/');
|
||||
expect(bundle.v2PublicPath).toBe('/console/admin/');
|
||||
expect(bundle.appConfigContent).toContain('# BEGIN NocoBase managed config');
|
||||
@@ -199,6 +225,10 @@ test('buildEnvProxyNginxBundle renders app.conf and index HTML with CDN-prefixed
|
||||
bundle.appConfigContent.indexOf('location = /console/api {'),
|
||||
);
|
||||
expect(bundle.appConfigContent).toContain('location ^~ /console/admin/ {');
|
||||
expect(bundle.appConfigContent).toContain('location ^~ /console/settings/assets/ {');
|
||||
expect(bundle.appConfigContent).toContain('/console/settings(?:/|$)');
|
||||
expect(bundle.appConfigContent).toContain('try_files $uri /index-settings.html =404;');
|
||||
expect(bundle.appConfigContent).not.toContain('/admin/settings');
|
||||
expect(bundle.appConfigContent).toContain('alias /workspace/.nocobase/proxy/nginx/demo/public/;');
|
||||
expect(bundle.appConfigContent).toContain('try_files $uri /index-v2.html =404;');
|
||||
expect(bundle.appConfigContent).toContain('location /console/ {');
|
||||
@@ -218,6 +248,9 @@ test('buildEnvProxyNginxBundle renders app.conf and index HTML with CDN-prefixed
|
||||
expect(bundle.mainConfigContent).toContain('include /workspace/.nocobase/proxy/nginx/*/app.conf;');
|
||||
expect(bundle.indexV1Content).toContain(`window['__webpack_public_path__'] = "/console/dist/2.1.0-beta.44/";`);
|
||||
expect(bundle.indexV1Content).toContain(`window['__nocobase_public_path__'] = "/console/";`);
|
||||
expect(bundle.indexV1Content.indexOf(`window['__nocobase_public_path__'] = '/nocobase/';`)).toBeLessThan(
|
||||
bundle.indexV1Content.indexOf(`window['__nocobase_public_path__'] = "/console/";`),
|
||||
);
|
||||
expect(bundle.indexV1Content).toContain(`window['__nocobase_app_client_entry_mode__'] = "modern-only";`);
|
||||
expect(bundle.indexV1Content).toContain('src="/console/dist/2.1.0-beta.44/browser-checker.js?v=1"');
|
||||
expect(bundle.indexV1Content).toContain('src="/console/dist/2.1.0-beta.44/assets/runtime.js"');
|
||||
@@ -226,6 +259,11 @@ test('buildEnvProxyNginxBundle renders app.conf and index HTML with CDN-prefixed
|
||||
expect(bundle.indexV2Content).toContain(`window['__nocobase_app_client_entry_mode__'] = "modern-only";`);
|
||||
expect(bundle.indexV2Content).toContain('src="/console/dist/2.1.0-beta.44/v/browser-checker.js?v=1"');
|
||||
expect(bundle.indexV2Content).toContain('src="/console/dist/2.1.0-beta.44/v/assets/runtime.js"');
|
||||
expect(bundle.indexSettingsContent).toContain(`window['__nocobase_public_path__'] = "/console/";`);
|
||||
expect(bundle.indexSettingsContent).toContain(`window['__webpack_public_path__'] = "";`);
|
||||
expect(bundle.indexSettingsContent).toContain(`window['__nocobase_modern_client_prefix__'] = "admin";`);
|
||||
expect(bundle.indexSettingsContent).toContain(`window['__nocobase_app_client_entry_mode__'] = "modern-only";`);
|
||||
expect(bundle.indexSettingsContent).toContain('src="/console/dist/2.1.0-beta.44/settings/assets/runtime.js"');
|
||||
});
|
||||
|
||||
test('buildEnvProxyNginxBundle omits the root redirect block for root-mounted apps', async () => {
|
||||
@@ -244,6 +282,8 @@ test('buildEnvProxyNginxBundle omits the root redirect block for root-mounted ap
|
||||
expect(bundle.appConfigContent).toContain('location ^~ /x/ {');
|
||||
expect(bundle.appConfigContent).toContain('if ($uri ~ ^/x/(?<portal>[A-Za-z0-9_-]+)$) {');
|
||||
expect(bundle.appConfigContent).toContain('location ^~ /v/ {');
|
||||
expect(bundle.appConfigContent).toContain('location ^~ /settings/assets/ {');
|
||||
expect(bundle.appConfigContent).toContain('/settings(?:/|$)');
|
||||
expect(bundle.appConfigContent).toContain('location ^~ /files/ {');
|
||||
expect(bundle.appConfigContent.match(/location \^~ \/files\//g)).toHaveLength(1);
|
||||
expect(bundle.appConfigContent).toContain('try_files $uri /index-v1.html =404;');
|
||||
@@ -278,6 +318,47 @@ test('buildManualEnvProxyNginxBundle derives the websocket path from appPublicPa
|
||||
expect(bundle.indexV2Content).toContain(`window['__nocobase_public_path__'] = "/console/v/";`);
|
||||
});
|
||||
|
||||
test('buildManualEnvProxyNginxBundle honors APP_MODERN_CLIENT_PREFIX', async () => {
|
||||
const root = await createTempRoot('nocobase-cli-env-proxy-nginx-manual-modern-prefix-');
|
||||
process.env.NB_CLI_ROOT = root;
|
||||
process.env.APP_MODERN_CLIENT_PREFIX = 'modern';
|
||||
const runtime = await createLocalRuntime(root);
|
||||
|
||||
const bundle = await buildManualEnvProxyNginxBundle({
|
||||
name: 'default',
|
||||
appPort: '13000',
|
||||
storagePath: runtime.env.storagePath,
|
||||
distRootPath: path.join(runtime.env.storagePath, 'dist-client'),
|
||||
runtimeVersion: '2.1.0-beta.44',
|
||||
appPublicPath: '/console/',
|
||||
});
|
||||
|
||||
expect(bundle.modernClientPrefix).toBe('modern');
|
||||
expect(bundle.v2PublicPath).toBe('/console/modern/');
|
||||
expect(bundle.appConfigContent).toContain('location ^~ /console/modern/ {');
|
||||
expect(bundle.indexSettingsContent).toContain(`window['__nocobase_modern_client_prefix__'] = "modern";`);
|
||||
});
|
||||
|
||||
test('env proxy bundles reject the reserved Settings modern prefix', async () => {
|
||||
const root = await createTempRoot('nocobase-cli-env-proxy-reserved-modern-prefix-');
|
||||
process.env.NB_CLI_ROOT = root;
|
||||
const runtime = await createLocalRuntime(root, { modernClientPrefix: 'settings' });
|
||||
|
||||
await expect(buildEnvProxyNginxBundle(runtime)).rejects.toThrow('APP_MODERN_CLIENT_PREFIX "settings" is reserved');
|
||||
|
||||
process.env.APP_MODERN_CLIENT_PREFIX = 'settings';
|
||||
await expect(
|
||||
buildManualEnvProxyNginxBundle({
|
||||
name: 'default',
|
||||
appPort: '13000',
|
||||
storagePath: runtime.env.storagePath,
|
||||
distRootPath: path.join(runtime.env.storagePath, 'dist-client'),
|
||||
runtimeVersion: '2.1.0-beta.44',
|
||||
appPublicPath: '/',
|
||||
}),
|
||||
).rejects.toThrow('APP_MODERN_CLIENT_PREFIX "settings" is reserved');
|
||||
});
|
||||
|
||||
test('buildManualEnvProxyNginxBundle uses an explicit CDN base url override', async () => {
|
||||
const root = await createTempRoot('nocobase-cli-env-proxy-nginx-manual-cdn-');
|
||||
process.env.NB_CLI_ROOT = root;
|
||||
@@ -300,6 +381,8 @@ test('buildManualEnvProxyNginxBundle uses an explicit CDN base url override', as
|
||||
expect(bundle.cdnBaseUrl).toBe('https://cdn.example.com/ui/');
|
||||
expect(bundle.indexV1Content).toContain('src="https://cdn.example.com/ui/browser-checker.js?v=1"');
|
||||
expect(bundle.indexV2Content).toContain('src="https://cdn.example.com/ui/v/browser-checker.js?v=1"');
|
||||
expect(bundle.indexSettingsContent).toContain('src="https://cdn.example.com/ui/settings/browser-checker.js?v=1"');
|
||||
expect(bundle.indexSettingsContent).toContain(`window['__webpack_public_path__'] = "https://cdn.example.com/ui/";`);
|
||||
});
|
||||
|
||||
test('buildManualEnvProxyNginxBundle reads versioned index files from distRootPath', async () => {
|
||||
@@ -310,6 +393,7 @@ test('buildManualEnvProxyNginxBundle reads versioned index files from distRootPa
|
||||
const versionRoot = path.join(distRootPath, '2.1.0-beta.44');
|
||||
|
||||
await mkdir(path.join(versionRoot, 'v'), { recursive: true });
|
||||
await mkdir(path.join(versionRoot, 'settings'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(versionRoot, 'index.html'),
|
||||
'<!doctype html><html><head><script>window[\'__nocobase_public_path__\'] = \'/\';</script><script src="/custom/browser-checker.js?v=1"></script><script type="module" src="/custom/assets/runtime.js"></script></head><body></body></html>',
|
||||
@@ -318,6 +402,10 @@ test('buildManualEnvProxyNginxBundle reads versioned index files from distRootPa
|
||||
path.join(versionRoot, 'v', 'index.html'),
|
||||
'<!doctype html><html><head><script>window[\'__nocobase_public_path__\'] = window[\'__nocobase_public_path__\'] || "/v/";</script><script src="/custom-v/browser-checker.js?v=1"></script><script type="module" src="/custom-v/assets/runtime.js"></script></head><body></body></html>',
|
||||
);
|
||||
await writeFile(
|
||||
path.join(versionRoot, 'settings', 'index.html'),
|
||||
'<!doctype html><html><head><script>window[\'__nocobase_public_path__\'] = window[\'__nocobase_public_path__\'] || "/settings/";</script><script src="/settings/browser-checker.js?v=1"></script><script type="module" src="/settings/assets/runtime.js"></script></head><body></body></html>',
|
||||
);
|
||||
|
||||
const bundle = await buildManualEnvProxyNginxBundle({
|
||||
name: 'default',
|
||||
@@ -331,6 +419,7 @@ test('buildManualEnvProxyNginxBundle reads versioned index files from distRootPa
|
||||
expect(bundle.appConfigContent).toContain(`alias ${distRootPath}/;`);
|
||||
expect(bundle.indexV1Content).toContain('src="/dist/2.1.0-beta.44/custom/browser-checker.js?v=1"');
|
||||
expect(bundle.indexV2Content).toContain('src="/custom-v/browser-checker.js?v=1"');
|
||||
expect(bundle.indexSettingsContent).toContain('src="/dist/2.1.0-beta.44/settings/browser-checker.js?v=1"');
|
||||
});
|
||||
|
||||
test('writeNginxProxyBundle overwrites non-managed app.conf when force is enabled', async () => {
|
||||
@@ -363,6 +452,26 @@ test('writeNginxProxyBundle overwrites non-managed app.conf when force is enable
|
||||
expect(content).toContain('listen 8080;');
|
||||
expect(content).toContain('location ^~ /x/ {');
|
||||
expect(content).toContain('/portals/main/$portal/dist/index.html');
|
||||
expect(await readFile(bundle.indexSettingsPath, 'utf8')).toBe(bundle.indexSettingsContent);
|
||||
});
|
||||
|
||||
test('writeCaddyProxyBundle writes the standalone Settings HTML', async () => {
|
||||
const root = await createTempRoot('nocobase-cli-env-proxy-caddy-write-');
|
||||
process.env.NB_CLI_ROOT = root;
|
||||
const runtime = await createLocalRuntime(root);
|
||||
|
||||
const result = await writeCaddyProxyBundle(
|
||||
runtime,
|
||||
{},
|
||||
{
|
||||
driver: 'local',
|
||||
runtimeCliRoot: root,
|
||||
upstreamHost: '127.0.0.1',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('created');
|
||||
expect(await readFile(result.bundle.indexSettingsPath, 'utf8')).toBe(result.bundle.indexSettingsContent);
|
||||
});
|
||||
|
||||
test('buildEnvProxyNginxBundle prefers CDN_BASE_URL from the managed env file', async () => {
|
||||
@@ -574,11 +683,20 @@ test('buildEnvProxyCaddyBundle renders app.caddy and index HTML files', async ()
|
||||
expect(bundle.appConfigPath).toBe(path.join(root, '.nocobase', 'proxy', 'caddy', 'demo', 'app.caddy'));
|
||||
expect(bundle.indexV1Path).toBe(path.join(root, '.nocobase', 'proxy', 'caddy', 'demo', 'public', 'index-v1.html'));
|
||||
expect(bundle.indexV2Path).toBe(path.join(root, '.nocobase', 'proxy', 'caddy', 'demo', 'public', 'index-v2.html'));
|
||||
expect(bundle.indexSettingsPath).toBe(
|
||||
path.join(root, '.nocobase', 'proxy', 'caddy', 'demo', 'public', 'index-settings.html'),
|
||||
);
|
||||
expect(bundle.appConfigContent).toContain(':80 {');
|
||||
expect(bundle.appConfigContent).not.toContain('route {');
|
||||
expect(bundle.appConfigContent).toContain('handle /console/files/* {');
|
||||
expect(bundle.appConfigContent).toContain('handle /files/* {');
|
||||
expect(bundle.appConfigContent).toContain('handle_path /console/admin/* {');
|
||||
expect(bundle.appConfigContent).toContain('handle_path /console/settings/assets/* {');
|
||||
expect(bundle.appConfigContent).toContain('header Cache-Control "public, max-age=31536000, immutable"');
|
||||
expect(bundle.appConfigContent).toContain('@settingsRoute path_regexp settingsRoute');
|
||||
expect(bundle.appConfigContent).toContain('^/console/settings(?:/.*)?$');
|
||||
expect(bundle.appConfigContent).toContain('try_files {path} /index-settings.html');
|
||||
expect(bundle.appConfigContent).not.toContain('/admin/settings');
|
||||
expect(bundle.appConfigContent).toContain('try_files {path} /index-v2.html');
|
||||
expect(bundle.appConfigContent).toContain('root * /workspace/.nocobase/proxy/caddy/demo/public');
|
||||
expect(bundle.mainConfigContent).toContain('import /workspace/.nocobase/proxy/caddy/*/app.caddy');
|
||||
@@ -586,6 +704,9 @@ test('buildEnvProxyCaddyBundle renders app.caddy and index HTML files', async ()
|
||||
expect(bundle.indexV1Content).toContain(`window['__nocobase_public_path__'] = "/console/";`);
|
||||
expect(bundle.indexV2Content).toContain(`window['__nocobase_public_path__'] = "/console/admin/";`);
|
||||
expect(bundle.indexV2Content).toContain(`window['__nocobase_modern_client_prefix__'] = "admin";`);
|
||||
expect(bundle.indexSettingsContent).toContain(`window['__nocobase_public_path__'] = "/console/";`);
|
||||
expect(bundle.indexSettingsContent).toContain(`window['__webpack_public_path__'] = "";`);
|
||||
expect(bundle.indexSettingsContent).toContain('src="/console/dist/2.1.0-beta.44/settings/assets/runtime.js"');
|
||||
});
|
||||
|
||||
test('buildManualEnvProxyCaddyBundle reads versioned index files from distRootPath', async () => {
|
||||
@@ -596,6 +717,7 @@ test('buildManualEnvProxyCaddyBundle reads versioned index files from distRootPa
|
||||
const versionRoot = path.join(distRootPath, '2.1.0-beta.44');
|
||||
|
||||
await mkdir(path.join(versionRoot, 'v'), { recursive: true });
|
||||
await mkdir(path.join(versionRoot, 'settings'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(versionRoot, 'index.html'),
|
||||
'<!doctype html><html><head><script>window[\'__nocobase_public_path__\'] = \'/\';</script><script src="/caddy-custom/browser-checker.js?v=1"></script><script type="module" src="/caddy-custom/assets/runtime.js"></script></head><body></body></html>',
|
||||
@@ -604,6 +726,10 @@ test('buildManualEnvProxyCaddyBundle reads versioned index files from distRootPa
|
||||
path.join(versionRoot, 'v', 'index.html'),
|
||||
'<!doctype html><html><head><script>window[\'__nocobase_public_path__\'] = window[\'__nocobase_public_path__\'] || "/v/";</script><script src="/caddy-custom-v/browser-checker.js?v=1"></script><script type="module" src="/caddy-custom-v/assets/runtime.js"></script></head><body></body></html>',
|
||||
);
|
||||
await writeFile(
|
||||
path.join(versionRoot, 'settings', 'index.html'),
|
||||
'<!doctype html><html><head><script>window[\'__nocobase_public_path__\'] = window[\'__nocobase_public_path__\'] || "/settings/";</script><script src="/settings/browser-checker.js?v=1"></script><script type="module" src="/settings/assets/runtime.js"></script></head><body></body></html>',
|
||||
);
|
||||
|
||||
const bundle = await buildManualEnvProxyCaddyBundle({
|
||||
name: 'default',
|
||||
@@ -620,6 +746,7 @@ test('buildManualEnvProxyCaddyBundle reads versioned index files from distRootPa
|
||||
expect(bundle.appConfigContent).toContain(`root * ${distRootPath}`);
|
||||
expect(bundle.indexV1Content).toContain('src="/dist/2.1.0-beta.44/caddy-custom/browser-checker.js?v=1"');
|
||||
expect(bundle.indexV2Content).toContain('src="/caddy-custom-v/browser-checker.js?v=1"');
|
||||
expect(bundle.indexSettingsContent).toContain('src="/dist/2.1.0-beta.44/settings/browser-checker.js?v=1"');
|
||||
});
|
||||
|
||||
test('buildEnvProxyAppConfig creates an editable Caddy app entry with a managed import block', () => {
|
||||
@@ -684,6 +811,9 @@ test('env proxy path helpers resolve the nginx entry, shared config, snippets, a
|
||||
expect(resolveEnvProxyNginxIndexOutputPath('staging', 'v1')).toBe(
|
||||
path.join(root, '.nocobase', 'proxy', 'nginx', 'staging', 'public', 'index-v1.html'),
|
||||
);
|
||||
expect(resolveEnvProxyNginxIndexOutputPath('staging', 'settings')).toBe(
|
||||
path.join(root, '.nocobase', 'proxy', 'nginx', 'staging', 'public', 'index-settings.html'),
|
||||
);
|
||||
expect(await mapProxyPathFromCliRoot(resolveEnvProxyAppOutputPath('staging'), { scope: 'global' })).toBe(
|
||||
'/workspace/.nocobase/proxy/nginx/staging/app.conf',
|
||||
);
|
||||
@@ -709,6 +839,9 @@ test('env proxy path helpers resolve the caddy entry, shared config, and index f
|
||||
expect(resolveEnvProxyCaddyIndexOutputPath('staging', 'v1')).toBe(
|
||||
path.join(root, '.nocobase', 'proxy', 'caddy', 'staging', 'public', 'index-v1.html'),
|
||||
);
|
||||
expect(resolveEnvProxyCaddyIndexOutputPath('staging', 'settings')).toBe(
|
||||
path.join(root, '.nocobase', 'proxy', 'caddy', 'staging', 'public', 'index-settings.html'),
|
||||
);
|
||||
expect(
|
||||
await mapProxyPathFromCliRoot(resolveEnvProxyAppOutputPath('staging', { provider: 'caddy' }), { scope: 'global' }),
|
||||
).toBe('/workspace/.nocobase/proxy/caddy/staging/app.caddy');
|
||||
|
||||
@@ -148,7 +148,7 @@ test('buildInstallArgv forwards app public path for new installs', () => {
|
||||
expect(argv).toContain('/console/');
|
||||
});
|
||||
|
||||
test('buildInstallArgv forwards AI portal init options for new installs', () => {
|
||||
test('buildInstallArgv does not forward portal init options for new installs', () => {
|
||||
const buildInstallArgv = (
|
||||
Init.prototype as unknown as {
|
||||
buildInstallArgv: (
|
||||
@@ -170,21 +170,17 @@ test('buildInstallArgv forwards AI portal init options for new installs', () =>
|
||||
version: 'beta',
|
||||
builtinDb: true,
|
||||
dbDialect: 'postgres',
|
||||
portalType: 'ai',
|
||||
portalName: 'admin',
|
||||
portalTemplate: '@nocobase/portal-template-default',
|
||||
portalType: 'no-code',
|
||||
portalName: 'main',
|
||||
},
|
||||
{
|
||||
yes: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(argv).toContain('--portal-type');
|
||||
expect(argv).toContain('ai');
|
||||
expect(argv).toContain('--portal-name');
|
||||
expect(argv).toContain('admin');
|
||||
expect(argv).toContain('--portal-template');
|
||||
expect(argv).toContain('@nocobase/portal-template-default');
|
||||
expect(argv).not.toContain('--portal-type');
|
||||
expect(argv).not.toContain('--portal-name');
|
||||
expect(argv).not.toContain('--portal-template');
|
||||
});
|
||||
|
||||
test('buildInstallArgv forwards hook script for new installs', () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ const mocks = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
printInfo: vi.fn(),
|
||||
printWarning: vi.fn(),
|
||||
ensureManagedEnvFileDefaults: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -40,6 +41,7 @@ beforeEach(() => {
|
||||
mocks.getEnv.mockReset();
|
||||
mocks.getEnv.mockResolvedValue(undefined);
|
||||
mocks.upsertEnv.mockResolvedValue(undefined);
|
||||
mocks.ensureManagedEnvFileDefaults.mockResolvedValue(undefined);
|
||||
mocks.inspectSkillsStatus.mockResolvedValue({ installed: false });
|
||||
mocks.installNocoBaseSkills.mockResolvedValue({ action: 'installed', status: {} });
|
||||
mocks.updateNocoBaseSkills.mockResolvedValue({ action: 'updated', status: {} });
|
||||
@@ -98,6 +100,14 @@ vi.mock('../lib/run-npm.ts', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../lib/managed-env-file.ts', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../lib/managed-env-file.js')>();
|
||||
return {
|
||||
...actual,
|
||||
ensureManagedEnvFileDefaults: mocks.ensureManagedEnvFileDefaults,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../lib/skills-manager.ts', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../lib/skills-manager.js')>();
|
||||
return {
|
||||
@@ -214,21 +224,16 @@ test('nb init continues from the browser UI result and runs env:add for an exist
|
||||
expect(webUiOptions?.stages[3]?.sectionTitle).toEqual({
|
||||
key: 'commands.init.webUi.downloadAppFiles.title',
|
||||
});
|
||||
expect(webUiOptions?.stages[4]?.catalog).toMatchObject({
|
||||
portalName: expect.objectContaining({ type: 'text' }),
|
||||
portalType: expect.objectContaining({ variant: 'radio' }),
|
||||
});
|
||||
expect(webUiOptions?.stages[4]?.catalog).not.toHaveProperty('portalTemplate');
|
||||
expect(webUiOptions?.stages[4]?.sectionTitle).toEqual({
|
||||
key: 'commands.init.webUi.portalType.title',
|
||||
key: 'commands.init.webUi.configureDatabase.title',
|
||||
});
|
||||
expect(webUiOptions?.stages[5]?.catalog).toMatchObject({
|
||||
expect(webUiOptions?.stages[4]?.catalog).toMatchObject({
|
||||
dbPassword: expect.any(Object),
|
||||
dbSchema: expect.any(Object),
|
||||
dbTablePrefix: expect.any(Object),
|
||||
dbUnderscored: expect.any(Object),
|
||||
});
|
||||
expect(webUiOptions?.stages[7]?.catalog).toMatchObject({
|
||||
expect(webUiOptions?.stages[6]?.catalog).toMatchObject({
|
||||
installApiBaseUrl: expect.any(Object),
|
||||
installAuthType: expect.any(Object),
|
||||
installAccessToken: expect.any(Object),
|
||||
@@ -528,8 +533,6 @@ test('nb init forwards download options to nb install for a new app flow', async
|
||||
'./storage/demoapp',
|
||||
'--app-public-path',
|
||||
'/console/',
|
||||
'--portal-name',
|
||||
'admin',
|
||||
'--source',
|
||||
'git',
|
||||
'--version',
|
||||
@@ -682,7 +685,7 @@ test('nb init does not expose duplicate username/password fields in the final co
|
||||
await Init.prototype.run.call(command);
|
||||
|
||||
const webUiOptions = mocks.runPromptCatalogWebUI.mock.calls[0]?.[0];
|
||||
const finalCatalog = webUiOptions?.stages[7]?.catalog as Record<string, unknown>;
|
||||
const finalCatalog = webUiOptions?.stages[6]?.catalog as Record<string, unknown>;
|
||||
expect(finalCatalog.installUsername).toBe(undefined);
|
||||
expect(finalCatalog.installPassword).toBe(undefined);
|
||||
expect(mocks.upsertEnv.mock.calls[0]?.[1]).toMatchObject({
|
||||
@@ -814,6 +817,17 @@ test('nb init saves env config before install starts so failures still leave the
|
||||
schemaVersion: ENV_CONFIG_SCHEMA_VERSION,
|
||||
timezone: expect.any(String),
|
||||
});
|
||||
expect(mocks.ensureManagedEnvFileDefaults.mock.calls[0]).toEqual([
|
||||
'demoapp',
|
||||
expect.objectContaining({
|
||||
kind: 'docker',
|
||||
source: 'docker',
|
||||
setupState: 'prepared',
|
||||
}),
|
||||
]);
|
||||
expect(mocks.ensureManagedEnvFileDefaults.mock.invocationCallOrder[0] < runCommand.mock.invocationCallOrder[0]).toBe(
|
||||
true,
|
||||
);
|
||||
expect(String(mocks.upsertEnv.mock.calls[0]?.[1]?.appKey ?? '')).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(String(mocks.error.mock.calls.at(-1)?.[0] ?? '')).toContain('install failed');
|
||||
});
|
||||
@@ -1804,8 +1818,6 @@ test('nb init --force allows reconfiguring an existing global env and warns befo
|
||||
'./nocobase',
|
||||
'--storage-path',
|
||||
'./storage/local5',
|
||||
'--portal-name',
|
||||
'admin',
|
||||
'--force',
|
||||
],
|
||||
]);
|
||||
@@ -2261,210 +2273,6 @@ test('nb init seeds the configured docker registry into web UI defaults', async
|
||||
}
|
||||
});
|
||||
|
||||
test('nb init seeds the configured default portal template into setup defaults', async () => {
|
||||
const { default: Init } = await import('../commands/init.js');
|
||||
const previous = process.env.NB_CLI_ROOT;
|
||||
const tempHome = await mkdtemp(path.join(os.tmpdir(), 'nocobase-init-portal-template-'));
|
||||
|
||||
const buildDynamicInitialValuesForInstall = (
|
||||
Init as unknown as {
|
||||
buildDynamicInitialValuesForInstall: (
|
||||
flags: { yes?: boolean; 'app-port'?: string; 'db-port'?: string; 'portal-template'?: string },
|
||||
presetValues: Record<string, string | number | boolean>,
|
||||
) => Promise<Record<string, string | number | boolean>>;
|
||||
}
|
||||
).buildDynamicInitialValuesForInstall;
|
||||
|
||||
const { setCliConfigValue, deleteCliConfigValue } = await import('../lib/cli-config.js');
|
||||
|
||||
try {
|
||||
process.env.NB_CLI_ROOT = tempHome;
|
||||
await setCliConfigValue('default-portal-template', '/workspace/portal-template', { scope: 'global' });
|
||||
|
||||
await expect(
|
||||
buildDynamicInitialValuesForInstall(
|
||||
{ yes: false },
|
||||
{
|
||||
appName: 'app1',
|
||||
appPort: '13000',
|
||||
source: 'docker',
|
||||
builtinDb: true,
|
||||
dbDialect: 'postgres',
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
portalTemplate: '/workspace/portal-template',
|
||||
});
|
||||
await expect(
|
||||
buildDynamicInitialValuesForInstall(
|
||||
{ yes: false, 'portal-template': '/explicit/portal-template' },
|
||||
{
|
||||
appName: 'app1',
|
||||
appPort: '13000',
|
||||
portalTemplate: '/explicit/portal-template',
|
||||
source: 'docker',
|
||||
builtinDb: true,
|
||||
dbDialect: 'postgres',
|
||||
},
|
||||
),
|
||||
).resolves.not.toHaveProperty('portalTemplate');
|
||||
} finally {
|
||||
await deleteCliConfigValue('default-portal-template', { scope: 'global' });
|
||||
if (previous === undefined) {
|
||||
delete process.env.NB_CLI_ROOT;
|
||||
} else {
|
||||
process.env.NB_CLI_ROOT = previous;
|
||||
}
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('nb init --yes uses the configured default portal template as a yes initial value', async () => {
|
||||
const { default: Init } = await import('../commands/init.js');
|
||||
const { setCliConfigValue, deleteCliConfigValue } = await import('../lib/cli-config.js');
|
||||
const { runPromptCatalog } =
|
||||
await vi.importActual<typeof import('../lib/prompt-catalog.js')>('../lib/prompt-catalog.js');
|
||||
const previousCliRoot = process.env.NB_CLI_ROOT;
|
||||
const originalArgv = process.argv;
|
||||
const tempHome = await mkdtemp(path.join(os.tmpdir(), 'nocobase-init-portal-template-yes-'));
|
||||
process.argv = [
|
||||
'node',
|
||||
'nb',
|
||||
'init',
|
||||
'--yes',
|
||||
'--env',
|
||||
'app2628',
|
||||
'--version=pr-10155',
|
||||
'--docker-registry=registry.cn-beijing.aliyuncs.com/nocobase/nocobase',
|
||||
'--docker-platform=linux/amd64',
|
||||
'--db-dialect=mysql',
|
||||
'--db-underscored',
|
||||
'--auth-type=basic',
|
||||
'--portal-type',
|
||||
'ai',
|
||||
];
|
||||
|
||||
try {
|
||||
process.env.NB_CLI_ROOT = tempHome;
|
||||
await setCliConfigValue('default-portal-template', '/workspace/portal-template', { scope: 'global' });
|
||||
mocks.runPromptCatalog.mockImplementation(runPromptCatalog);
|
||||
|
||||
const runCommand = vi.fn(async () => undefined);
|
||||
const command = Object.assign(Object.create(Init.prototype), {
|
||||
parse: vi.fn(async () => ({
|
||||
flags: {
|
||||
yes: true,
|
||||
ui: false,
|
||||
env: 'app2628',
|
||||
version: 'pr-10155',
|
||||
'docker-registry': 'registry.cn-beijing.aliyuncs.com/nocobase/nocobase',
|
||||
'docker-platform': 'linux/amd64',
|
||||
'db-dialect': 'mysql',
|
||||
'db-underscored': true,
|
||||
'auth-type': 'basic',
|
||||
'portal-type': 'ai',
|
||||
},
|
||||
})),
|
||||
config: { runCommand },
|
||||
log: mocks.log,
|
||||
error: mocks.error,
|
||||
exit: (code?: number) => {
|
||||
throw new Error(`unexpected exit: ${code ?? 'unknown'}`);
|
||||
},
|
||||
});
|
||||
|
||||
await Init.prototype.run.call(command);
|
||||
|
||||
expect(mocks.runPromptCatalog.mock.calls[0]?.[1]?.yesInitialValues).toMatchObject({
|
||||
portalTemplate: '/workspace/portal-template',
|
||||
});
|
||||
const installArgv = runCommand.mock.calls.find(([commandName]) => commandName === 'install')?.[1] as
|
||||
| string[]
|
||||
| undefined;
|
||||
expect(installArgv).toEqual(expect.arrayContaining(['--portal-template', '/workspace/portal-template']));
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
await deleteCliConfigValue('default-portal-template', { scope: 'global' });
|
||||
if (previousCliRoot === undefined) {
|
||||
delete process.env.NB_CLI_ROOT;
|
||||
} else {
|
||||
process.env.NB_CLI_ROOT = previousCliRoot;
|
||||
}
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('nb init --yes uses the built-in portal template when no config default is set', async () => {
|
||||
const { default: Init } = await import('../commands/init.js');
|
||||
const { runPromptCatalog } =
|
||||
await vi.importActual<typeof import('../lib/prompt-catalog.js')>('../lib/prompt-catalog.js');
|
||||
const previousCliRoot = process.env.NB_CLI_ROOT;
|
||||
const originalArgv = process.argv;
|
||||
const tempHome = await mkdtemp(path.join(os.tmpdir(), 'nocobase-init-portal-template-builtin-'));
|
||||
process.argv = [
|
||||
'node',
|
||||
'nb',
|
||||
'init',
|
||||
'--yes',
|
||||
'--env',
|
||||
'app2628',
|
||||
'--version=pr-10155',
|
||||
'--docker-registry=registry.cn-beijing.aliyuncs.com/nocobase/nocobase',
|
||||
'--docker-platform=linux/amd64',
|
||||
'--db-dialect=mysql',
|
||||
'--db-underscored',
|
||||
'--auth-type=basic',
|
||||
'--portal-type',
|
||||
'ai',
|
||||
];
|
||||
|
||||
try {
|
||||
process.env.NB_CLI_ROOT = tempHome;
|
||||
mocks.runPromptCatalog.mockImplementation(runPromptCatalog);
|
||||
|
||||
const runCommand = vi.fn(async () => undefined);
|
||||
const command = Object.assign(Object.create(Init.prototype), {
|
||||
parse: vi.fn(async () => ({
|
||||
flags: {
|
||||
yes: true,
|
||||
ui: false,
|
||||
env: 'app2628',
|
||||
version: 'pr-10155',
|
||||
'docker-registry': 'registry.cn-beijing.aliyuncs.com/nocobase/nocobase',
|
||||
'docker-platform': 'linux/amd64',
|
||||
'db-dialect': 'mysql',
|
||||
'db-underscored': true,
|
||||
'auth-type': 'basic',
|
||||
'portal-type': 'ai',
|
||||
},
|
||||
})),
|
||||
config: { runCommand },
|
||||
log: mocks.log,
|
||||
error: mocks.error,
|
||||
exit: (code?: number) => {
|
||||
throw new Error(`unexpected exit: ${code ?? 'unknown'}`);
|
||||
},
|
||||
});
|
||||
|
||||
await Init.prototype.run.call(command);
|
||||
|
||||
const installArgv = runCommand.mock.calls.find(([commandName]) => commandName === 'install')?.[1] as
|
||||
| string[]
|
||||
| undefined;
|
||||
expect(installArgv).toEqual(
|
||||
expect.arrayContaining(['--portal-template', '@nocobase/portal-template-default']),
|
||||
);
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
if (previousCliRoot === undefined) {
|
||||
delete process.env.NB_CLI_ROOT;
|
||||
} else {
|
||||
process.env.NB_CLI_ROOT = previousCliRoot;
|
||||
}
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('nb init preserves argument values that contain spaces when building install argv', async () => {
|
||||
const { default: Init } = await import('../commands/init.js');
|
||||
const originalArgv = process.argv;
|
||||
|
||||
@@ -252,7 +252,7 @@ test('install syncs oauth env connection after the app becomes ready', async ()
|
||||
]);
|
||||
});
|
||||
|
||||
test('install delegates portal initialization to app startup', async () => {
|
||||
test('install delegates portal initialization to app startup without Registry sync', async () => {
|
||||
const { default: Install } = await import('../commands/install.js');
|
||||
|
||||
const waitForAppHealthCheck = vi.fn(async () => undefined);
|
||||
@@ -302,10 +302,7 @@ test('install delegates portal initialization to app startup', async () => {
|
||||
'app:start',
|
||||
['--env', 'app1', '--yes', '--no-sync-licensed-plugins', '--hook-command', 'init'],
|
||||
]);
|
||||
expect(runCommand.mock.calls[1]).toEqual([
|
||||
'portal:registry:sync',
|
||||
['admin', '--env', 'app1', '--yes', '--build'],
|
||||
]);
|
||||
expect(runCommand.mock.calls.some(([name]) => name === 'portal:registry:sync')).toBe(false);
|
||||
});
|
||||
|
||||
test('install saves the resolved app url before delegating startup', async () => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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 { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, expect, test } from 'vitest';
|
||||
import { ensureManagedEnvFileDefaults } from '../lib/managed-env-file.js';
|
||||
|
||||
const createdRoots: string[] = [];
|
||||
const originalNbCliRoot = process.env.NB_CLI_ROOT;
|
||||
|
||||
async function createTempRoot() {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'nocobase-cli-managed-env-file-'));
|
||||
createdRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalNbCliRoot === undefined) {
|
||||
delete process.env.NB_CLI_ROOT;
|
||||
} else {
|
||||
process.env.NB_CLI_ROOT = originalNbCliRoot;
|
||||
}
|
||||
|
||||
for (const root of createdRoots.splice(0)) {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureManagedEnvFileDefaults creates the default managed app .env file', async () => {
|
||||
const root = await createTempRoot();
|
||||
process.env.NB_CLI_ROOT = root;
|
||||
|
||||
const envFilePath = await ensureManagedEnvFileDefaults('local', {
|
||||
kind: 'local',
|
||||
appPath: './apps/local',
|
||||
});
|
||||
|
||||
expect(envFilePath).toBe(path.join(root, 'apps/local/.env'));
|
||||
await expect(readFile(envFilePath as string, 'utf8')).resolves.toBe(
|
||||
[
|
||||
'APP_DISCOVERY_ADAPTER=local',
|
||||
'APP_PROCESS_ADAPTER=local',
|
||||
'APP_CLIENT_ENTRY_MODE=modern-only',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('ensureManagedEnvFileDefaults preserves existing .env values and appends missing defaults', async () => {
|
||||
const root = await createTempRoot();
|
||||
process.env.NB_CLI_ROOT = root;
|
||||
const envFilePath = path.join(root, 'apps/local/.env');
|
||||
await mkdir(path.dirname(envFilePath), { recursive: true });
|
||||
await writeFile(envFilePath, 'APP_DISCOVERY_ADAPTER=custom\nCUSTOM_VALUE=1', 'utf8');
|
||||
|
||||
await expect(
|
||||
ensureManagedEnvFileDefaults('local', {
|
||||
kind: 'local',
|
||||
appPath: './apps/local',
|
||||
}),
|
||||
).resolves.toBe(envFilePath);
|
||||
|
||||
await expect(readFile(envFilePath, 'utf8')).resolves.toBe(
|
||||
[
|
||||
'APP_DISCOVERY_ADAPTER=custom',
|
||||
'CUSTOM_VALUE=1',
|
||||
'APP_PROCESS_ADAPTER=local',
|
||||
'APP_CLIENT_ENTRY_MODE=modern-only',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
@@ -19,7 +19,7 @@ test('buildInitAppEnvVarsFromConfig includes initial portal type and portal sett
|
||||
rootPassword: 'admin123',
|
||||
rootNickname: 'Super Admin',
|
||||
portalType: 'ai',
|
||||
portalName: 'admin',
|
||||
portalName: 'main',
|
||||
portalTemplate: '@nocobase/portal-template-default',
|
||||
}),
|
||||
).toEqual({
|
||||
@@ -29,7 +29,7 @@ test('buildInitAppEnvVarsFromConfig includes initial portal type and portal sett
|
||||
INIT_ROOT_PASSWORD: 'admin123',
|
||||
INIT_ROOT_NICKNAME: 'Super Admin',
|
||||
INIT_PORTAL_TYPE: 'ai',
|
||||
INIT_PORTAL_NAME: 'admin',
|
||||
INIT_PORTAL_NAME: 'main',
|
||||
INIT_PORTAL_TEMPLATE: '@nocobase/portal-template-default',
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,7 @@ test('buildInitAppEnvVarsFromConfig can omit portal init settings', () => {
|
||||
lang: 'en-US',
|
||||
rootUsername: 'nocobase',
|
||||
portalType: 'ai',
|
||||
portalName: 'admin',
|
||||
portalName: 'main',
|
||||
portalTemplate: '/tmp/portal-template',
|
||||
},
|
||||
{ includePortal: false },
|
||||
|
||||
@@ -53,6 +53,13 @@ function createEnv(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function expectPosixMode(actual: number | undefined, expected: number): void {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
expect(actual === undefined ? actual : actual & 0o777).toBe(expected);
|
||||
}
|
||||
|
||||
async function preparePortalWorkspace(params: {
|
||||
storagePath: string;
|
||||
app?: string;
|
||||
@@ -173,11 +180,11 @@ test('updates env files, builds, and syncs the portal record locally without upl
|
||||
'LOCAL_ONLY=true\n' +
|
||||
'NOCOBASE_API_URL=http://localhost:13000/console/api/__app/crm\n',
|
||||
);
|
||||
expect((await fsp.stat(path.join(storagePath, 'portals'))).mode & 0o777).toBe(0o755);
|
||||
expect((await fsp.stat(path.join(storagePath, 'portals', 'crm'))).mode & 0o777).toBe(0o755);
|
||||
expect((await fsp.stat(portalDir)).mode & 0o777).toBe(0o755);
|
||||
expect((await fsp.stat(path.join(portalDir, 'dist'))).mode & 0o777).toBe(0o755);
|
||||
expect((await fsp.stat(path.join(portalDir, 'dist', 'index.html'))).mode & 0o777).toBe(0o644);
|
||||
expectPosixMode((await fsp.stat(path.join(storagePath, 'portals'))).mode, 0o755);
|
||||
expectPosixMode((await fsp.stat(path.join(storagePath, 'portals', 'crm'))).mode, 0o755);
|
||||
expectPosixMode((await fsp.stat(portalDir)).mode, 0o755);
|
||||
expectPosixMode((await fsp.stat(path.join(portalDir, 'dist'))).mode, 0o755);
|
||||
expectPosixMode((await fsp.stat(path.join(portalDir, 'dist', 'index.html'))).mode, 0o644);
|
||||
});
|
||||
|
||||
test('docker deploy builds and syncs the portal record without uploading dist', async () => {
|
||||
@@ -233,13 +240,13 @@ test('http deploy builds, packs dist, and uploads it', async () => {
|
||||
onentry: (entry) => {
|
||||
entries.push(entry.path);
|
||||
if (entry.path === 'index.html') {
|
||||
expect(entry.mode).toBe(0o644);
|
||||
expectPosixMode(entry.mode, 0o644);
|
||||
}
|
||||
if (entry.path === 'assets') {
|
||||
expect(entry.mode).toBe(0o755);
|
||||
expectPosixMode(entry.mode, 0o755);
|
||||
}
|
||||
if (entry.path === 'assets/index.js') {
|
||||
expect(entry.mode).toBe(0o644);
|
||||
expectPosixMode(entry.mode, 0o644);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -102,6 +102,6 @@ test('portal destroy resolves the current env name before destroying', async ()
|
||||
['App: main'],
|
||||
['Base: /x/cba/'],
|
||||
['Record: deleted'],
|
||||
['Workspace: deleted (/Users/chen/test6/remote1/source/storage/portals/main/cba)'],
|
||||
['Portal files: deleted (/Users/chen/test6/remote1/source/storage/portals/main/cba)'],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -131,5 +131,42 @@ test('portal registry sync forwards selected items and overwrite/build flags', a
|
||||
overwriteUi: true,
|
||||
diff: undefined,
|
||||
build: true,
|
||||
skipIfUnsupported: undefined,
|
||||
onWarning: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
test('portal registry sync can skip unsupported services for automatic setup', async () => {
|
||||
const { default: PortalRegistrySync } = await import('../commands/portal/registry/sync.js');
|
||||
mocks.syncPortalRegistries.mockResolvedValueOnce({
|
||||
portal: 'customer',
|
||||
portalDir: '/tmp/storage/portals/main/customer',
|
||||
items: ['@nocobase/all'],
|
||||
skippedItems: [],
|
||||
status: 'unsupported',
|
||||
});
|
||||
const command = Object.assign(Object.create(PortalRegistrySync.prototype), {
|
||||
argv: [],
|
||||
parse: vi.fn(async () => ({
|
||||
args: { portal: 'customer', items: [] },
|
||||
flags: { build: true, yes: true, 'skip-if-unsupported': true },
|
||||
})),
|
||||
config: { pjson: { version: '2.2.0-test.1' } },
|
||||
error: (message: string) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
});
|
||||
|
||||
await PortalRegistrySync.prototype.run.call(command);
|
||||
|
||||
expect(mocks.syncPortalRegistries).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
portal: 'customer',
|
||||
env,
|
||||
build: true,
|
||||
skipIfUnsupported: true,
|
||||
onWarning: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(mocks.printSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -75,6 +75,10 @@ async function runGit(args: string[], cwd?: string) {
|
||||
return await execFileAsync('git', args, { cwd });
|
||||
}
|
||||
|
||||
function normalizeLineEndings(value: string): string {
|
||||
return value.replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
@@ -303,7 +307,7 @@ test('pull and push Git-managed source through the configured repository path',
|
||||
changed: true,
|
||||
});
|
||||
const portalDir = path.join(storagePath, 'portals', 'main', 'customer');
|
||||
await expect(fsp.readFile(path.join(portalDir, 'src', 'index.tsx'), 'utf-8')).resolves.toBe(
|
||||
expect(normalizeLineEndings(await fsp.readFile(path.join(portalDir, 'src', 'index.tsx'), 'utf-8'))).toBe(
|
||||
'export default "remote";\n',
|
||||
);
|
||||
await expect(fsp.readFile(path.join(portalDir, 'portal.config.json'), 'utf-8')).resolves.toBe(
|
||||
@@ -325,8 +329,8 @@ test('pull and push Git-managed source through the configured repository path',
|
||||
});
|
||||
|
||||
const verifyRepo = await makeTempDir('nocobase-cli-portal-git-verify-');
|
||||
await runGit(['clone', remoteRepo, verifyRepo]);
|
||||
await expect(fsp.readFile(path.join(verifyRepo, 'customer', 'src', 'index.tsx'), 'utf-8')).resolves.toBe(
|
||||
await runGit(['clone', '--branch', 'main', remoteRepo, verifyRepo]);
|
||||
expect(normalizeLineEndings(await fsp.readFile(path.join(verifyRepo, 'customer', 'src', 'index.tsx'), 'utf-8'))).toBe(
|
||||
'export default "local";\n',
|
||||
);
|
||||
});
|
||||
@@ -392,7 +396,7 @@ test('push creates configured Git branch and uses repository root by default', a
|
||||
|
||||
const verifyRepo = await makeTempDir('nocobase-cli-portal-git-empty-verify-');
|
||||
await runGit(['clone', '--branch', 'main', remoteRepoUrl, verifyRepo]);
|
||||
await expect(fsp.readFile(path.join(verifyRepo, 'src', 'index.tsx'), 'utf-8')).resolves.toBe(
|
||||
expect(normalizeLineEndings(await fsp.readFile(path.join(verifyRepo, 'src', 'index.tsx'), 'utf-8'))).toBe(
|
||||
'export default "first push";\n',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import crypto from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { stdin as stdinStream, stdout as stdoutStream } from 'node:process';
|
||||
import { getEnv, upsertEnv } from '../lib/auth-store.ts';
|
||||
import { getEnv, type EnvConfigEntry, upsertEnv } from '../lib/auth-store.ts';
|
||||
import {
|
||||
type PromptBlock,
|
||||
type PromptCatalogValues,
|
||||
@@ -43,18 +43,15 @@ import { omitKeys, pickKeys } from '../lib/object-utils.ts';
|
||||
import { ENV_CONFIG_SCHEMA_VERSION } from '../lib/env-config.js';
|
||||
import { printInfo, printStage, printVerbose, printWarning } from '../lib/ui.js';
|
||||
import { persistHookScript } from '../lib/hook-script.js';
|
||||
import { ensureManagedEnvFileDefaults } from '../lib/managed-env-file.js';
|
||||
import Download from './download.ts';
|
||||
import EnvAdd from './env/add.ts';
|
||||
import Install, { defaultDbPortForDialect } from './install.ts';
|
||||
|
||||
const DEFAULT_INIT_API_BASE_URL = 'http://localhost:13000/api';
|
||||
const DEFAULT_INIT_APP_NAME = 'local';
|
||||
const DEFAULT_INIT_PORTAL_TYPE = 'no-code';
|
||||
const DEFAULT_INIT_PORTAL_NAME = 'admin';
|
||||
const DEFAULT_INIT_PORTAL_TEMPLATE = '@nocobase/portal-template-default';
|
||||
const DOWNLOAD_OUTPUT_DIR_PROMPT = Download.prompts.outputDir as TextPromptBlock;
|
||||
const INIT_SETUP_MODES = ['install-new', 'manage-local', 'connect-remote'] as const;
|
||||
const INIT_PORTAL_TYPES = ['no-code', 'ai'] as const;
|
||||
type InitSetupMode = (typeof INIT_SETUP_MODES)[number];
|
||||
const INIT_ENV_ADD_FLAG_NAMES = [
|
||||
'locale',
|
||||
@@ -111,10 +108,6 @@ function isInstallLikeSetupMode(values: PromptCatalogValues | Record<string, unk
|
||||
return !isRemoteSetupMode(values);
|
||||
}
|
||||
|
||||
function isAiMode(values: PromptCatalogValues | Record<string, unknown>): boolean {
|
||||
return String(values.portalType ?? DEFAULT_INIT_PORTAL_TYPE).trim() === 'ai';
|
||||
}
|
||||
|
||||
function remoteConnectionOnly<T extends PromptBlock>(def: T): T {
|
||||
return withExtraHidden(def, (values) => !isRemoteSetupMode(values));
|
||||
}
|
||||
@@ -471,42 +464,6 @@ Prompt modes:
|
||||
devDependencies: installLikeDownloadExecutionOnly(Download.prompts.devDependencies),
|
||||
build: installLikeDownloadExecutionOnly(Download.prompts.build),
|
||||
buildDts: installLikeDownloadExecutionOnly(Download.prompts.buildDts),
|
||||
portalType: installNewOnly({
|
||||
type: 'select',
|
||||
variant: 'radio',
|
||||
message: initText('prompts.portalType.message'),
|
||||
options: [
|
||||
{
|
||||
value: 'no-code',
|
||||
label: initText('prompts.portalType.noCodeLabel'),
|
||||
hint: initText('prompts.portalType.noCodeHint'),
|
||||
},
|
||||
{
|
||||
value: 'ai',
|
||||
label: initText('prompts.portalType.aiLabel'),
|
||||
hint: initText('prompts.portalType.aiHint'),
|
||||
},
|
||||
],
|
||||
initialValue: DEFAULT_INIT_PORTAL_TYPE,
|
||||
yesInitialValue: DEFAULT_INIT_PORTAL_TYPE,
|
||||
required: true,
|
||||
}),
|
||||
portalName: installNewOnly({
|
||||
type: 'text',
|
||||
message: initText('prompts.portalName.message'),
|
||||
placeholder: DEFAULT_INIT_PORTAL_NAME,
|
||||
initialValue: DEFAULT_INIT_PORTAL_NAME,
|
||||
yesInitialValue: DEFAULT_INIT_PORTAL_NAME,
|
||||
required: true,
|
||||
}),
|
||||
portalTemplate: installNewOnly({
|
||||
type: 'text',
|
||||
message: initText('prompts.portalTemplate.message'),
|
||||
placeholder: DEFAULT_INIT_PORTAL_TEMPLATE,
|
||||
yesInitialValue: DEFAULT_INIT_PORTAL_TEMPLATE,
|
||||
hidden: (values) => !isAiMode(values),
|
||||
required: true,
|
||||
}),
|
||||
dbDialect: installLikeOnly(Install.dbPrompts.dbDialect),
|
||||
builtinDb: installLikeOnly(Install.dbPrompts.builtinDb),
|
||||
builtinDbImage: installLikeOnly(Install.dbPrompts.builtinDbImage),
|
||||
@@ -530,19 +487,12 @@ Prompt modes:
|
||||
|
||||
private buildPromptCatalog(
|
||||
flags: { 'skip-auth'?: boolean },
|
||||
options: { defaultApiHost: string; defaultPortalTemplate?: string },
|
||||
options: { defaultApiHost: string },
|
||||
): PromptsCatalog {
|
||||
const prompts: PromptsCatalog = {
|
||||
...Init.prompts,
|
||||
installApiBaseUrl: createInstallConnectionApiBaseUrlPrompt(options.defaultApiHost),
|
||||
};
|
||||
const defaultPortalTemplate = String(options.defaultPortalTemplate ?? '').trim();
|
||||
if (defaultPortalTemplate) {
|
||||
prompts.portalTemplate = {
|
||||
...prompts.portalTemplate,
|
||||
yesInitialValue: defaultPortalTemplate,
|
||||
} as TextPromptBlock;
|
||||
}
|
||||
|
||||
if (flags['skip-auth']) {
|
||||
const accessTokenPrompt: TextPromptBlock = {
|
||||
@@ -601,16 +551,6 @@ Prompt modes:
|
||||
description: 'Setup mode: install a new app, manage a local app by reusing its database, or connect a remote app',
|
||||
options: [...INIT_SETUP_MODES],
|
||||
}),
|
||||
'portal-type': Flags.string({
|
||||
description: 'Initial portal type: no-code or ai',
|
||||
options: [...INIT_PORTAL_TYPES],
|
||||
}),
|
||||
'portal-name': Flags.string({
|
||||
description: 'Initial portal name',
|
||||
}),
|
||||
'portal-template': Flags.string({
|
||||
description: 'Initial portal template npm package or local path when --portal-type ai is used',
|
||||
}),
|
||||
ui: Flags.boolean({
|
||||
description: 'Open the guided setup flow in a local browser form (not valid with --yes)',
|
||||
default: false,
|
||||
@@ -842,8 +782,7 @@ Prompt modes:
|
||||
);
|
||||
const defaultUiHost = await resolveDefaultUiHost();
|
||||
const defaultApiHost = await resolveDefaultApiHost();
|
||||
const defaultPortalTemplate = String(dynamicInitialValues.portalTemplate ?? '').trim();
|
||||
const promptCatalog = this.buildPromptCatalog(normalizedFlags, { defaultApiHost, defaultPortalTemplate });
|
||||
const promptCatalog = this.buildPromptCatalog(normalizedFlags, { defaultApiHost });
|
||||
if (useBrowserUi) {
|
||||
presetValues = await runPromptCatalogWebUI({
|
||||
stages: Init.buildWebUiStages(promptCatalog),
|
||||
@@ -873,7 +812,7 @@ Prompt modes:
|
||||
? { setupMode: normalizeInitSetupMode(presetValues.hasNocobase) }
|
||||
: {}),
|
||||
},
|
||||
yesInitialValues: pickKeys(dynamicInitialValues, ['portalTemplate']),
|
||||
yesInitialValues: {},
|
||||
values: presetValues,
|
||||
yes: normalizedFlags.yes || useBrowserUi || !interactive,
|
||||
hooks: {
|
||||
@@ -963,7 +902,6 @@ Prompt modes:
|
||||
'app-root-path'?: string;
|
||||
'app-port'?: string;
|
||||
'storage-path'?: string;
|
||||
'portal-template'?: string;
|
||||
'db-port'?: string;
|
||||
yes?: boolean;
|
||||
},
|
||||
@@ -972,8 +910,7 @@ Prompt modes:
|
||||
const out: PromptInitialValues = {};
|
||||
|
||||
const shouldResolveAppInitialValues =
|
||||
!Object.prototype.hasOwnProperty.call(presetValues, 'appPort') ||
|
||||
!Object.prototype.hasOwnProperty.call(presetValues, 'portalTemplate');
|
||||
!Object.prototype.hasOwnProperty.call(presetValues, 'appPort');
|
||||
if (shouldResolveAppInitialValues) {
|
||||
const appInitialValues = await Install.buildAppPromptInitialValues({
|
||||
envName: String(presetValues.appName ?? '').trim(),
|
||||
@@ -982,23 +919,12 @@ Prompt modes:
|
||||
'app-path': flags['app-path'] ?? '',
|
||||
'app-root-path': flags['app-root-path'] ?? '',
|
||||
'storage-path': flags['storage-path'] ?? '',
|
||||
'portal-template':
|
||||
flags['portal-template'] ??
|
||||
(Object.prototype.hasOwnProperty.call(presetValues, 'portalTemplate')
|
||||
? String(presetValues.portalTemplate ?? '')
|
||||
: undefined),
|
||||
},
|
||||
warnOnPortFallback: false,
|
||||
});
|
||||
if (appInitialValues.appPort !== undefined && !Object.prototype.hasOwnProperty.call(presetValues, 'appPort')) {
|
||||
out.appPort = appInitialValues.appPort;
|
||||
}
|
||||
if (
|
||||
appInitialValues.portalTemplate !== undefined &&
|
||||
!Object.prototype.hasOwnProperty.call(presetValues, 'portalTemplate')
|
||||
) {
|
||||
out.portalTemplate = appInitialValues.portalTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
const downloadSeed = { ...presetValues };
|
||||
@@ -1084,14 +1010,6 @@ Prompt modes:
|
||||
buildDts: c.buildDts,
|
||||
} satisfies PromptsCatalog,
|
||||
},
|
||||
{
|
||||
sectionTitle: initText('webUi.portalType.title'),
|
||||
sectionDescription: initText('webUi.portalType.description'),
|
||||
catalog: {
|
||||
portalName: c.portalName,
|
||||
portalType: c.portalType,
|
||||
} satisfies PromptsCatalog,
|
||||
},
|
||||
{
|
||||
sectionTitle: initText('webUi.configureDatabase.title'),
|
||||
sectionDescription: initText('webUi.configureDatabase.description'),
|
||||
@@ -1147,9 +1065,6 @@ Prompt modes:
|
||||
'app-port'?: string;
|
||||
'storage-path'?: string;
|
||||
'app-public-path'?: string;
|
||||
'portal-type'?: string;
|
||||
'portal-name'?: string;
|
||||
'portal-template'?: string;
|
||||
'root-username'?: string;
|
||||
'root-email'?: string;
|
||||
'root-password'?: string;
|
||||
@@ -1242,15 +1157,6 @@ Prompt modes:
|
||||
if (flags['app-public-path'] !== undefined && String(flags['app-public-path']).trim() !== '') {
|
||||
preset.appPublicPath = String(flags['app-public-path']).trim();
|
||||
}
|
||||
if (flags['portal-type'] !== undefined && String(flags['portal-type']).trim() !== '') {
|
||||
preset.portalType = String(flags['portal-type']).trim();
|
||||
}
|
||||
if (flags['portal-name'] !== undefined && String(flags['portal-name']).trim() !== '') {
|
||||
preset.portalName = String(flags['portal-name']).trim();
|
||||
}
|
||||
if (flags['portal-template'] !== undefined && String(flags['portal-template']).trim() !== '') {
|
||||
preset.portalTemplate = String(flags['portal-template']).trim();
|
||||
}
|
||||
if (flags['root-username'] !== undefined) {
|
||||
preset.rootUsername = String(flags['root-username'] ?? '').trim();
|
||||
}
|
||||
@@ -1401,9 +1307,6 @@ Prompt modes:
|
||||
const existingEnv = await getEnv(envName, { scope: resolveDefaultConfigScope() });
|
||||
const appPort = String(results.appPort ?? '').trim();
|
||||
const appPublicPath = String(results.appPublicPath ?? '').trim();
|
||||
const portalType = String(results.portalType ?? '').trim();
|
||||
const portalName = String(results.portalName ?? '').trim();
|
||||
const portalTemplate = String(results.portalTemplate ?? '').trim();
|
||||
const source = String(results.source ?? '').trim();
|
||||
const version = resolveInitDownloadVersion(results);
|
||||
const dockerRegistry = String(results.dockerRegistry ?? '').trim();
|
||||
@@ -1431,7 +1334,9 @@ Prompt modes:
|
||||
const dbSchema = String(results.dbSchema ?? '').trim();
|
||||
const dbTablePrefix = String(results.dbTablePrefix ?? '').trim();
|
||||
const apiBaseUrl = String(results.apiBaseUrl ?? '').trim();
|
||||
const authType = String(results.authType ?? '').trim() || 'oauth';
|
||||
const authTypeInput = String(results.authType ?? '').trim();
|
||||
const authType: EnvConfigEntry['authType'] =
|
||||
authTypeInput === 'basic' || authTypeInput === 'token' || authTypeInput === 'oauth' ? authTypeInput : 'oauth';
|
||||
const authUsername = authType === 'basic' ? String(results.username ?? results.rootUsername ?? '').trim() : '';
|
||||
const accessToken = String(results.accessToken ?? '');
|
||||
const skipDownload = results.skipDownload === true;
|
||||
@@ -1454,59 +1359,57 @@ Prompt modes:
|
||||
results.appKey = appKey;
|
||||
results.timeZone = timeZone;
|
||||
|
||||
await upsertEnv(
|
||||
envName,
|
||||
{
|
||||
schemaVersion: ENV_CONFIG_SCHEMA_VERSION,
|
||||
...(source === 'docker'
|
||||
? { kind: 'docker' }
|
||||
: source || appPath || appRootPath
|
||||
? { kind: 'local' }
|
||||
: appPort
|
||||
? { kind: 'http' }
|
||||
: {}),
|
||||
...(apiBaseUrl ? { apiBaseUrl } : appPort ? { apiBaseUrl: `http://127.0.0.1:${appPort}/api` } : {}),
|
||||
...(authType ? { authType } : {}),
|
||||
...(authUsername ? { authUsername } : {}),
|
||||
...((authType === 'token' || authType === 'basic') && accessToken ? { accessToken } : {}),
|
||||
...(source ? { source } : {}),
|
||||
...(version ? { downloadVersion: version } : {}),
|
||||
...(dockerRegistry ? { dockerRegistry } : {}),
|
||||
...(dockerPlatform ? { dockerPlatform } : {}),
|
||||
...(gitUrl ? { gitUrl } : {}),
|
||||
...(npmRegistry ? { npmRegistry } : {}),
|
||||
...(hookScript ? { hookScript } : {}),
|
||||
...(appPath ? { appPath } : {}),
|
||||
...(appRootPath && !areConfiguredPathsEquivalent(appRootPath, derivedAppRootPath) ? { appRootPath } : {}),
|
||||
...(storagePath && !areConfiguredPathsEquivalent(storagePath, derivedStoragePath) ? { storagePath } : {}),
|
||||
...(appPort ? { appPort } : {}),
|
||||
...(appPublicPath ? { appPublicPath } : {}),
|
||||
...(portalType && portalType !== DEFAULT_INIT_PORTAL_TYPE ? { portalType } : {}),
|
||||
...(portalName ? { portalName } : {}),
|
||||
...(portalTemplate ? { portalTemplate } : {}),
|
||||
...(appKey ? { appKey } : {}),
|
||||
...(timeZone ? { timezone: timeZone } : {}),
|
||||
...(!skipDownload && results.devDependencies !== undefined
|
||||
? { devDependencies: Boolean(results.devDependencies) }
|
||||
: {}),
|
||||
...(!skipDownload && results.build !== undefined ? { build: Boolean(results.build) } : {}),
|
||||
...(!skipDownload && results.buildDts !== undefined ? { buildDts: Boolean(results.buildDts) } : {}),
|
||||
...(builtinDb !== undefined ? { builtinDb } : {}),
|
||||
...(dbDialect ? { dbDialect } : {}),
|
||||
...(builtinDbImage || builtinDb === false ? { builtinDbImage: builtinDbImage || undefined } : {}),
|
||||
...(dbHost ? { dbHost } : {}),
|
||||
...(dbPort ? { dbPort } : {}),
|
||||
...(dbDatabase ? { dbDatabase } : {}),
|
||||
...(dbUser ? { dbUser } : {}),
|
||||
...(dbPassword ? { dbPassword } : {}),
|
||||
...(dbSchema ? { dbSchema } : {}),
|
||||
...(dbTablePrefix ? { dbTablePrefix } : {}),
|
||||
...(results.dbUnderscored !== undefined ? { dbUnderscored: Boolean(results.dbUnderscored) } : {}),
|
||||
setupState: 'prepared',
|
||||
...(String(results.lang ?? '').trim() ? { lang: String(results.lang ?? '').trim() } : {}),
|
||||
},
|
||||
{ scope: resolveDefaultConfigScope() },
|
||||
);
|
||||
const savedEnvConfig: Partial<EnvConfigEntry> = {
|
||||
schemaVersion: ENV_CONFIG_SCHEMA_VERSION,
|
||||
...(source === 'docker'
|
||||
? { kind: 'docker' }
|
||||
: source || appPath || appRootPath
|
||||
? { kind: 'local' }
|
||||
: appPort
|
||||
? { kind: 'http' }
|
||||
: {}),
|
||||
...(apiBaseUrl ? { apiBaseUrl } : appPort ? { apiBaseUrl: `http://127.0.0.1:${appPort}/api` } : {}),
|
||||
...(authType ? { authType } : {}),
|
||||
...(authUsername ? { authUsername } : {}),
|
||||
...((authType === 'token' || authType === 'basic') && accessToken ? { accessToken } : {}),
|
||||
...(source ? { source } : {}),
|
||||
...(version ? { downloadVersion: version } : {}),
|
||||
...(dockerRegistry ? { dockerRegistry } : {}),
|
||||
...(dockerPlatform ? { dockerPlatform } : {}),
|
||||
...(gitUrl ? { gitUrl } : {}),
|
||||
...(npmRegistry ? { npmRegistry } : {}),
|
||||
...(hookScript ? { hookScript } : {}),
|
||||
...(appPath ? { appPath } : {}),
|
||||
...(appRootPath && !areConfiguredPathsEquivalent(appRootPath, derivedAppRootPath) ? { appRootPath } : {}),
|
||||
...(storagePath && !areConfiguredPathsEquivalent(storagePath, derivedStoragePath) ? { storagePath } : {}),
|
||||
...(appPort ? { appPort } : {}),
|
||||
...(appPublicPath ? { appPublicPath } : {}),
|
||||
...(appKey ? { appKey } : {}),
|
||||
...(timeZone ? { timezone: timeZone } : {}),
|
||||
...(!skipDownload && results.devDependencies !== undefined
|
||||
? { devDependencies: Boolean(results.devDependencies) }
|
||||
: {}),
|
||||
...(!skipDownload && results.build !== undefined ? { build: Boolean(results.build) } : {}),
|
||||
...(!skipDownload && results.buildDts !== undefined ? { buildDts: Boolean(results.buildDts) } : {}),
|
||||
...(builtinDb !== undefined ? { builtinDb } : {}),
|
||||
...(dbDialect ? { dbDialect } : {}),
|
||||
...(builtinDbImage || builtinDb === false ? { builtinDbImage: builtinDbImage || undefined } : {}),
|
||||
...(dbHost ? { dbHost } : {}),
|
||||
...(dbPort ? { dbPort } : {}),
|
||||
...(dbDatabase ? { dbDatabase } : {}),
|
||||
...(dbUser ? { dbUser } : {}),
|
||||
...(dbPassword ? { dbPassword } : {}),
|
||||
...(dbSchema ? { dbSchema } : {}),
|
||||
...(dbTablePrefix ? { dbTablePrefix } : {}),
|
||||
...(results.dbUnderscored !== undefined ? { dbUnderscored: Boolean(results.dbUnderscored) } : {}),
|
||||
setupState: 'prepared',
|
||||
...(String(results.lang ?? '').trim() ? { lang: String(results.lang ?? '').trim() } : {}),
|
||||
};
|
||||
|
||||
await upsertEnv(envName, savedEnvConfig, { scope: resolveDefaultConfigScope() });
|
||||
if (source === 'docker' || appPath) {
|
||||
await ensureManagedEnvFileDefaults(envName, savedEnvConfig);
|
||||
}
|
||||
}
|
||||
|
||||
private buildEnvAddArgv(results: Record<string, string | number | boolean>): string[] {
|
||||
@@ -1544,9 +1447,6 @@ Prompt modes:
|
||||
'skip-auth'?: boolean;
|
||||
'skip-download'?: boolean;
|
||||
'app-path'?: string;
|
||||
'portal-type'?: string;
|
||||
'portal-name'?: string;
|
||||
'portal-template'?: string;
|
||||
'db-host'?: string;
|
||||
'db-schema'?: string;
|
||||
'db-table-prefix'?: string;
|
||||
@@ -1655,21 +1555,6 @@ Prompt modes:
|
||||
argv.push('--app-public-path', appPublicPath);
|
||||
}
|
||||
|
||||
const portalType = String(results.portalType ?? '').trim();
|
||||
if (portalType && portalType !== DEFAULT_INIT_PORTAL_TYPE) {
|
||||
argv.push('--portal-type', portalType);
|
||||
}
|
||||
|
||||
const portalName = String(results.portalName ?? '').trim();
|
||||
if (portalName) {
|
||||
argv.push('--portal-name', portalName);
|
||||
}
|
||||
|
||||
const portalTemplate = String(results.portalTemplate ?? '').trim();
|
||||
if (portalTemplate) {
|
||||
argv.push('--portal-template', portalTemplate);
|
||||
}
|
||||
|
||||
if (flags.force) {
|
||||
argv.push('--force');
|
||||
}
|
||||
@@ -1942,14 +1827,6 @@ Prompt modes:
|
||||
delete normalized.rootPassword;
|
||||
delete normalized.rootNickname;
|
||||
}
|
||||
const portalType = normalizeConnectionString(normalized.portalType) || DEFAULT_INIT_PORTAL_TYPE;
|
||||
normalized.portalType = portalType;
|
||||
normalized.portalName = normalizeConnectionString(normalized.portalName) || DEFAULT_INIT_PORTAL_NAME;
|
||||
if (portalType === 'ai') {
|
||||
normalized.portalTemplate = normalizeConnectionString(normalized.portalTemplate);
|
||||
} else {
|
||||
delete normalized.portalTemplate;
|
||||
}
|
||||
delete normalized.installApiBaseUrl;
|
||||
delete normalized.installAuthType;
|
||||
delete normalized.installUsername;
|
||||
|
||||
@@ -68,6 +68,7 @@ import { buildStoredEnvConfig, type StoredEnvConfig } from '../lib/env-config.js
|
||||
import { resolveDockerEnvFileArg } from '../lib/docker-env-file.ts';
|
||||
import { startDockerLogFollower } from '../lib/docker-log-stream.js';
|
||||
import { buildInitAppEnvVarsFromConfig } from '../lib/managed-init-env.js';
|
||||
import { ensureManagedEnvFileDefaults } from '../lib/managed-env-file.js';
|
||||
import {
|
||||
buildHookContext,
|
||||
persistHookScript,
|
||||
@@ -104,8 +105,8 @@ const DEFAULT_INSTALL_ROOT_EMAIL = 'admin@nocobase.com';
|
||||
const DEFAULT_INSTALL_ROOT_PASSWORD = 'admin123';
|
||||
const DEFAULT_INSTALL_ROOT_NICKNAME = 'Super Admin';
|
||||
const DEFAULT_INSTALL_API_HOST = '127.0.0.1';
|
||||
const DEFAULT_INSTALL_PORTAL_TYPE = 'no-code';
|
||||
const DEFAULT_INSTALL_PORTAL_NAME = 'admin';
|
||||
const DEFAULT_INSTALL_PORTAL_TYPE = 'ai';
|
||||
const DEFAULT_INSTALL_PORTAL_NAME = 'main';
|
||||
const DEFAULT_INSTALL_PORTAL_TEMPLATE = '@nocobase/portal-template-default';
|
||||
const INSTALL_PORTAL_TYPES = ['no-code', 'ai'] as const;
|
||||
|
||||
@@ -3079,11 +3080,16 @@ export default class Install extends Command {
|
||||
dbResults: Record<string, PromptValue>;
|
||||
rootResults: Record<string, PromptValue>;
|
||||
envAddResults: Record<string, PromptValue>;
|
||||
ensureEnvFileDefaults?: boolean;
|
||||
}): Promise<void> {
|
||||
const defaultApiHost = await resolveDefaultApiHost();
|
||||
await upsertEnv(params.envName, Install.buildSavedEnvConfig(params, { defaultApiHost }), {
|
||||
const savedEnvConfig = Install.buildSavedEnvConfig(params, { defaultApiHost });
|
||||
await upsertEnv(params.envName, savedEnvConfig, {
|
||||
scope: resolveDefaultConfigScope(),
|
||||
});
|
||||
if (params.ensureEnvFileDefaults !== false) {
|
||||
await ensureManagedEnvFileDefaults(params.envName, savedEnvConfig);
|
||||
}
|
||||
await setCurrentEnv(params.envName, { scope: resolveDefaultConfigScope() });
|
||||
}
|
||||
|
||||
@@ -3486,6 +3492,7 @@ export default class Install extends Command {
|
||||
dbResults,
|
||||
rootResults,
|
||||
envAddResults,
|
||||
ensureEnvFileDefaults: false,
|
||||
});
|
||||
if (!parsed['skip-save-env-log']) {
|
||||
printInfo(`Saved env config for "${envName}".`);
|
||||
@@ -3558,11 +3565,6 @@ export default class Install extends Command {
|
||||
if (shouldStartApp) {
|
||||
this.logStage('Starting NocoBase');
|
||||
await this.config.runCommand('app:start', this.buildAppStartArgv({ envName, verbose: parsed.verbose }));
|
||||
if (isAiMode(appResults)) {
|
||||
const portalName =
|
||||
String(appResults.portalName ?? DEFAULT_INSTALL_PORTAL_NAME).trim() || DEFAULT_INSTALL_PORTAL_NAME;
|
||||
await this.config.runCommand('portal:registry:sync', [portalName, '--env', envName, '--yes', '--build']);
|
||||
}
|
||||
}
|
||||
|
||||
await this.syncInstalledEnvConnection({
|
||||
|
||||
@@ -14,7 +14,7 @@ import { translateCli } from '../../../lib/cli-locale.js';
|
||||
import { ensureCrossEnvConfirmed, hasExplicitEnvSelection } from '../../../lib/env-guard.js';
|
||||
import { resolveAccessToken } from '../../../lib/env-auth.js';
|
||||
import { syncPortalRegistries } from '../../../lib/portal-registry-sync.js';
|
||||
import { printInfo, printSuccess } from '../../../lib/ui.js';
|
||||
import { printInfo, printSuccess, printWarning } from '../../../lib/ui.js';
|
||||
|
||||
const portalRegistrySyncText = (key: string, values?: Record<string, unknown>, fallback?: string) =>
|
||||
translateCli(`commands.portalRegistrySync.${key}`, values, { fallback });
|
||||
@@ -62,6 +62,11 @@ export default class PortalRegistrySync extends Command {
|
||||
description: 'Build the portal after installing Registry items',
|
||||
default: false,
|
||||
}),
|
||||
'skip-if-unsupported': Flags.boolean({
|
||||
description: 'Skip automatic Registry installation when the service does not expose Portal Registries',
|
||||
hidden: true,
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
@@ -96,8 +101,13 @@ export default class PortalRegistrySync extends Command {
|
||||
overwriteUi: flags['overwrite-ui'],
|
||||
diff: flags.diff,
|
||||
build: flags.build,
|
||||
skipIfUnsupported: flags['skip-if-unsupported'],
|
||||
token,
|
||||
onWarning: (message) => printWarning(message),
|
||||
});
|
||||
if (result.status === 'unsupported') {
|
||||
return;
|
||||
}
|
||||
if (result.status === 'diffed') {
|
||||
printInfo(
|
||||
portalRegistrySyncText(
|
||||
|
||||
@@ -33,6 +33,7 @@ const DEFAULT_API_BASE_PATH = '/api/';
|
||||
const DEFAULT_WS_PATH = '/ws';
|
||||
const DEFAULT_PLUGIN_STATICS_PATH = '/static/plugins/';
|
||||
const DEFAULT_MODERN_CLIENT_PREFIX = 'v';
|
||||
const SETTINGS_CLIENT_PREFIX = 'settings';
|
||||
const DEFAULT_APP_CLIENT_ENTRY_MODE = 'legacy-default';
|
||||
const APP_CLIENT_ENTRY_MODES = new Set(['legacy-default', 'modern-default', 'modern-only']);
|
||||
const DEFAULT_API_CLIENT_STORAGE_PREFIX = 'NOCOBASE_';
|
||||
@@ -107,6 +108,7 @@ export type EnvProxyNginxBundle = {
|
||||
appConfigPath: string;
|
||||
indexV1Path: string;
|
||||
indexV2Path: string;
|
||||
indexSettingsPath: string;
|
||||
mainConfigPath: string;
|
||||
snippetsDir: string;
|
||||
appPublicPath: string;
|
||||
@@ -121,6 +123,7 @@ export type EnvProxyNginxBundle = {
|
||||
mainConfigContent: string;
|
||||
indexV1Content: string;
|
||||
indexV2Content: string;
|
||||
indexSettingsContent: string;
|
||||
};
|
||||
|
||||
export type ManualEnvProxyNginxInput = {
|
||||
@@ -143,6 +146,7 @@ export type EnvProxyCaddyBundle = {
|
||||
appConfigPath: string;
|
||||
indexV1Path: string;
|
||||
indexV2Path: string;
|
||||
indexSettingsPath: string;
|
||||
mainConfigPath: string;
|
||||
appPublicPath: string;
|
||||
apiBasePath: string;
|
||||
@@ -156,9 +160,11 @@ export type EnvProxyCaddyBundle = {
|
||||
mainConfigContent: string;
|
||||
indexV1Content: string;
|
||||
indexV2Content: string;
|
||||
indexSettingsContent: string;
|
||||
};
|
||||
|
||||
type EnvProxyTemplateContext = {
|
||||
activeVersion: string;
|
||||
appPublicPath: string;
|
||||
apiBasePath: string;
|
||||
apiPort: string;
|
||||
@@ -229,7 +235,11 @@ function normalizeModernClientPrefix(value?: string) {
|
||||
const segment = String(value || '')
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
return segment || DEFAULT_MODERN_CLIENT_PREFIX;
|
||||
const normalized = segment || DEFAULT_MODERN_CLIENT_PREFIX;
|
||||
if (normalized === SETTINGS_CLIENT_PREFIX) {
|
||||
throw new Error('APP_MODERN_CLIENT_PREFIX "settings" is reserved for the standalone Settings application.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeAppClientEntryMode(value?: string) {
|
||||
@@ -536,7 +546,7 @@ function createManualProxyEnvSettings(input: ManualEnvProxyNginxInput): ProxyEnv
|
||||
pluginStaticsPath: prefixRuntimePath(appPublicPath, DEFAULT_PLUGIN_STATICS_PATH, {
|
||||
trailingSlash: true,
|
||||
}),
|
||||
modernClientPrefix: DEFAULT_MODERN_CLIENT_PREFIX,
|
||||
modernClientPrefix: normalizeModernClientPrefix(process.env.APP_MODERN_CLIENT_PREFIX),
|
||||
appClientEntryMode: normalizeAppClientEntryMode(process.env.APP_CLIENT_ENTRY_MODE),
|
||||
cdnBaseUrl: trimValue(input.cdnBaseUrl),
|
||||
apiClientStoragePrefix: DEFAULT_API_CLIENT_STORAGE_PREFIX,
|
||||
@@ -783,6 +793,7 @@ type EnvProxyNginxRenderContext = {
|
||||
appPublicPath: string;
|
||||
backendUrl: string;
|
||||
cdnBaseUrl: string;
|
||||
hasExplicitCdnBaseUrl: boolean;
|
||||
distPath: string;
|
||||
distRootDir: string;
|
||||
entryDir: string;
|
||||
@@ -791,6 +802,7 @@ type EnvProxyNginxRenderContext = {
|
||||
esmCdnSuffix: string;
|
||||
indexV1Path: string;
|
||||
indexV2Path: string;
|
||||
indexSettingsPath: string;
|
||||
modernClientPrefix: string;
|
||||
appClientEntryMode: string;
|
||||
proxyHost: string;
|
||||
@@ -810,6 +822,9 @@ function buildNginxManagedConfigBlock(context: EnvProxyNginxRenderContext): stri
|
||||
const apiBasePathNoTrailingSlash = trimTrailingSlash(context.apiBasePath);
|
||||
const appPublicPathNoTrailingSlash = trimTrailingSlash(context.appPublicPath);
|
||||
const fileAccessPath = `${context.appPublicPath}files/`;
|
||||
const settingsAssetsPath = `${context.appPublicPath}settings/assets/`;
|
||||
const settingsAssetsRoot = joinRuntimePath(context.distRootDir, `${context.activeVersion}/settings/assets`);
|
||||
const settingsRoutePattern = `^${escapeRegExp(context.appPublicPath)}settings(?:/|$)`;
|
||||
const isRootMounted = context.appPublicPath === '/';
|
||||
const appPublicPathRedirectBlock = isRootMounted
|
||||
? ''
|
||||
@@ -883,6 +898,17 @@ function buildNginxManagedConfigBlock(context: EnvProxyNginxRenderContext): stri
|
||||
` return 302 ${context.v2PublicPath}$is_args$args;`,
|
||||
' }',
|
||||
'',
|
||||
` location ^~ ${settingsAssetsPath} {`,
|
||||
` alias ${settingsAssetsRoot}/;`,
|
||||
` include ${context.snippetsDir}/dist-location.conf;`,
|
||||
' }',
|
||||
'',
|
||||
` location ~ ${settingsRoutePattern} {`,
|
||||
` root ${context.publicDir};`,
|
||||
` try_files $uri /index-settings.html =404;`,
|
||||
` include ${context.snippetsDir}/spa-location.conf;`,
|
||||
' }',
|
||||
'',
|
||||
` location ^~ ${context.v2PublicPath} {`,
|
||||
` alias ${context.publicDir}/;`,
|
||||
` try_files $uri /index-v2.html =404;`,
|
||||
@@ -961,11 +987,14 @@ function buildNginxPortalLocationBlock(context: EnvProxyNginxRenderContext): str
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildNginxRuntimeConfig(context: EnvProxyNginxRenderContext, variant: 'v1' | 'v2'): Record<string, boolean | string> {
|
||||
function buildNginxRuntimeConfig(
|
||||
context: EnvProxyNginxRenderContext,
|
||||
variant: 'v1' | 'v2' | 'settings',
|
||||
): Record<string, boolean | string> {
|
||||
return {
|
||||
__webpack_public_path__: context.cdnBaseUrl,
|
||||
__nocobase_public_path__: variant === 'v1' ? context.appPublicPath : context.v2PublicPath,
|
||||
...(variant === 'v2' ? { __nocobase_modern_client_prefix__: context.modernClientPrefix } : {}),
|
||||
__webpack_public_path__: variant === 'settings' ? (context.hasExplicitCdnBaseUrl ? context.cdnBaseUrl : '') : context.cdnBaseUrl,
|
||||
__nocobase_public_path__: variant === 'v2' ? context.v2PublicPath : context.appPublicPath,
|
||||
...(variant !== 'v1' ? { __nocobase_modern_client_prefix__: context.modernClientPrefix } : {}),
|
||||
__nocobase_app_client_entry_mode__: context.appClientEntryMode,
|
||||
__nocobase_api_base_url__: context.apiBasePath,
|
||||
__nocobase_api_client_storage_prefix__: context.apiClientStoragePrefix,
|
||||
@@ -979,7 +1008,10 @@ function buildNginxRuntimeConfig(context: EnvProxyNginxRenderContext, variant: '
|
||||
};
|
||||
}
|
||||
|
||||
function buildCaddyRuntimeConfig(context: EnvProxyCaddyRenderContext, variant: 'v1' | 'v2'): Record<string, boolean | string> {
|
||||
function buildCaddyRuntimeConfig(
|
||||
context: EnvProxyCaddyRenderContext,
|
||||
variant: 'v1' | 'v2' | 'settings',
|
||||
): Record<string, boolean | string> {
|
||||
return buildNginxRuntimeConfig(context, variant);
|
||||
}
|
||||
|
||||
@@ -1015,6 +1047,7 @@ async function buildEnvProxyNginxRenderContext(
|
||||
appPublicPath: source.settings.appPublicPath,
|
||||
backendUrl,
|
||||
cdnBaseUrl: ensureTrailingSlash(cdnBaseUrl),
|
||||
hasExplicitCdnBaseUrl: Boolean(source.settings.cdnBaseUrl),
|
||||
distPath: source.settings.distPath,
|
||||
distRootDir: mappedDistRootDir,
|
||||
entryDir: mappedEntryDir,
|
||||
@@ -1023,6 +1056,10 @@ async function buildEnvProxyNginxRenderContext(
|
||||
esmCdnSuffix: source.settings.esmCdnSuffix,
|
||||
indexV1Path: await mapProxyPathFromCliRoot(resolveEnvProxyNginxIndexOutputPath(source.envName, 'v1', { scope: options?.scope }), options),
|
||||
indexV2Path: await mapProxyPathFromCliRoot(resolveEnvProxyNginxIndexOutputPath(source.envName, 'v2', { scope: options?.scope }), options),
|
||||
indexSettingsPath: await mapProxyPathFromCliRoot(
|
||||
resolveEnvProxyNginxIndexOutputPath(source.envName, 'settings', { scope: options?.scope }),
|
||||
options,
|
||||
),
|
||||
modernClientPrefix: source.settings.modernClientPrefix,
|
||||
appClientEntryMode: source.settings.appClientEntryMode,
|
||||
proxyHost,
|
||||
@@ -1142,7 +1179,7 @@ export function resolveEnvProxyNginxPublicOutputDir(envName: string, options?: {
|
||||
|
||||
export function resolveEnvProxyNginxIndexOutputPath(
|
||||
envName: string,
|
||||
variant: 'v1' | 'v2',
|
||||
variant: 'v1' | 'v2' | 'settings',
|
||||
options?: { scope?: CliHomeScope },
|
||||
): string {
|
||||
return path.join(resolveEnvProxyNginxPublicOutputDir(envName, { scope: options?.scope }), `index-${variant}.html`);
|
||||
@@ -1168,7 +1205,7 @@ export function resolveEnvProxyCaddyPublicOutputDir(envName: string, options?: {
|
||||
|
||||
export function resolveEnvProxyCaddyIndexOutputPath(
|
||||
envName: string,
|
||||
variant: 'v1' | 'v2',
|
||||
variant: 'v1' | 'v2' | 'settings',
|
||||
options?: { scope?: CliHomeScope },
|
||||
): string {
|
||||
return path.join(resolveEnvProxyCaddyPublicOutputDir(envName, { scope: options?.scope }), `index-${variant}.html`);
|
||||
@@ -1201,16 +1238,20 @@ async function buildNginxBundleFromSource(
|
||||
const mainTemplate = await readEnvProxyNginxAssetText('nocobase.conf.tpl');
|
||||
const sourceIndexV1Path = path.join(source.distRootPath, context.activeVersion, 'index.html');
|
||||
const sourceIndexV2Path = path.join(source.distRootPath, context.activeVersion, DEFAULT_MODERN_CLIENT_PREFIX, 'index.html');
|
||||
const [sourceIndexV1Content, sourceIndexV2Content] = await Promise.all([
|
||||
const sourceIndexSettingsPath = path.join(source.distRootPath, context.activeVersion, 'settings', 'index.html');
|
||||
const [sourceIndexV1Content, sourceIndexV2Content, sourceIndexSettingsContent] = await Promise.all([
|
||||
readFile(sourceIndexV1Path, 'utf8'),
|
||||
readFile(sourceIndexV2Path, 'utf8'),
|
||||
readFile(sourceIndexSettingsPath, 'utf8'),
|
||||
]);
|
||||
const v1RuntimeScript = buildRuntimeConfigScriptTag(buildNginxRuntimeConfig(context, 'v1'));
|
||||
const v2RuntimeScript = buildRuntimeConfigScriptTag(buildNginxRuntimeConfig(context, 'v2'));
|
||||
const settingsRuntimeScript = buildRuntimeConfigScriptTag(buildNginxRuntimeConfig(context, 'settings'));
|
||||
const sourceV1PublicPath = extractRuntimePublicPath(sourceIndexV1Content);
|
||||
const sourceV2PublicPath = extractRuntimePublicPath(sourceIndexV2Content);
|
||||
const indexV1AssetPublicPath = context.cdnBaseUrl;
|
||||
const indexV2AssetPublicPath = `${trimTrailingSlash(context.cdnBaseUrl)}/${DEFAULT_MODERN_CLIENT_PREFIX}/`;
|
||||
const indexSettingsAssetPublicPath = `${trimTrailingSlash(context.cdnBaseUrl)}/settings/`;
|
||||
const appConfigIncludePath = await mapProxyPathFromCliRoot(
|
||||
path.join(resolveEnvProxyProviderRootDir('nginx', { scope: options?.scope }), '*', resolveEnvProxyFileSpec('nginx').appFilename),
|
||||
options,
|
||||
@@ -1238,6 +1279,7 @@ async function buildNginxBundleFromSource(
|
||||
appConfigPath: resolveEnvProxyAppOutputPath(source.envName, { scope: options?.scope, provider: 'nginx' }),
|
||||
indexV1Path: resolveEnvProxyNginxIndexOutputPath(source.envName, 'v1', { scope: options?.scope }),
|
||||
indexV2Path: resolveEnvProxyNginxIndexOutputPath(source.envName, 'v2', { scope: options?.scope }),
|
||||
indexSettingsPath: resolveEnvProxyNginxIndexOutputPath(source.envName, 'settings', { scope: options?.scope }),
|
||||
mainConfigPath: resolveEnvProxyMainOutputPath({ scope: options?.scope, provider: 'nginx' }),
|
||||
snippetsDir: resolveEnvProxyNginxSnippetsOutputDir({ scope: options?.scope }),
|
||||
appPublicPath: context.appPublicPath,
|
||||
@@ -1261,6 +1303,10 @@ async function buildNginxBundleFromSource(
|
||||
rewriteHtmlAssetPublicPath(sourceIndexV2Content, sourceV2PublicPath, indexV2AssetPublicPath),
|
||||
v2RuntimeScript,
|
||||
),
|
||||
indexSettingsContent: injectRuntimeScriptIntoHtml(
|
||||
rewriteHtmlAssetPublicPath(sourceIndexSettingsContent, '/settings/', indexSettingsAssetPublicPath),
|
||||
settingsRuntimeScript,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1289,21 +1335,26 @@ async function buildCaddyBundleFromSource(
|
||||
const context = await buildEnvProxyCaddyRenderContextFromSource(source, options);
|
||||
const sourceIndexV1Path = path.join(source.distRootPath, context.activeVersion, 'index.html');
|
||||
const sourceIndexV2Path = path.join(source.distRootPath, context.activeVersion, DEFAULT_MODERN_CLIENT_PREFIX, 'index.html');
|
||||
const [sourceIndexV1Content, sourceIndexV2Content] = await Promise.all([
|
||||
const sourceIndexSettingsPath = path.join(source.distRootPath, context.activeVersion, 'settings', 'index.html');
|
||||
const [sourceIndexV1Content, sourceIndexV2Content, sourceIndexSettingsContent] = await Promise.all([
|
||||
readFile(sourceIndexV1Path, 'utf8'),
|
||||
readFile(sourceIndexV2Path, 'utf8'),
|
||||
readFile(sourceIndexSettingsPath, 'utf8'),
|
||||
]);
|
||||
const v1RuntimeScript = buildRuntimeConfigScriptTag(buildCaddyRuntimeConfig(context, 'v1'));
|
||||
const v2RuntimeScript = buildRuntimeConfigScriptTag(buildCaddyRuntimeConfig(context, 'v2'));
|
||||
const settingsRuntimeScript = buildRuntimeConfigScriptTag(buildCaddyRuntimeConfig(context, 'settings'));
|
||||
const sourceV1PublicPath = extractRuntimePublicPath(sourceIndexV1Content);
|
||||
const sourceV2PublicPath = extractRuntimePublicPath(sourceIndexV2Content);
|
||||
const indexV1AssetPublicPath = context.cdnBaseUrl;
|
||||
const indexV2AssetPublicPath = `${trimTrailingSlash(context.cdnBaseUrl)}/${DEFAULT_MODERN_CLIENT_PREFIX}/`;
|
||||
const indexSettingsAssetPublicPath = `${trimTrailingSlash(context.cdnBaseUrl)}/settings/`;
|
||||
const appConfigPath = resolveEnvProxyAppOutputPath(source.envName, { scope: options?.scope, provider: 'caddy' });
|
||||
const entryDir = resolveEnvProxyEntryDir(source.envName, { scope: options?.scope, provider: 'caddy' });
|
||||
const publicDir = resolveEnvProxyCaddyPublicOutputDir(source.envName, { scope: options?.scope });
|
||||
const renderedPublicDir = await mapProxyPathFromCliRoot(publicDir, { ...options, provider: 'caddy' });
|
||||
const appConfigContent = renderCaddyAppTemplate(buildCaddySiteAddress(), {
|
||||
activeVersion: context.activeVersion,
|
||||
appPublicPath: context.appPublicPath,
|
||||
apiBasePath: context.apiBasePath,
|
||||
apiPort: context.apiPort,
|
||||
@@ -1325,6 +1376,7 @@ async function buildCaddyBundleFromSource(
|
||||
appConfigPath,
|
||||
indexV1Path: resolveEnvProxyCaddyIndexOutputPath(source.envName, 'v1', { scope: options?.scope }),
|
||||
indexV2Path: resolveEnvProxyCaddyIndexOutputPath(source.envName, 'v2', { scope: options?.scope }),
|
||||
indexSettingsPath: resolveEnvProxyCaddyIndexOutputPath(source.envName, 'settings', { scope: options?.scope }),
|
||||
mainConfigPath: resolveEnvProxyMainOutputPath({ scope: options?.scope, provider: 'caddy' }),
|
||||
appPublicPath: context.appPublicPath,
|
||||
apiBasePath: context.apiBasePath,
|
||||
@@ -1344,6 +1396,10 @@ async function buildCaddyBundleFromSource(
|
||||
rewriteHtmlAssetPublicPath(sourceIndexV2Content, sourceV2PublicPath, indexV2AssetPublicPath),
|
||||
v2RuntimeScript,
|
||||
),
|
||||
indexSettingsContent: injectRuntimeScriptIntoHtml(
|
||||
rewriteHtmlAssetPublicPath(sourceIndexSettingsContent, '/settings/', indexSettingsAssetPublicPath),
|
||||
settingsRuntimeScript,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1645,6 +1701,9 @@ function renderCaddyAppTemplate(siteAddress: string, context: EnvProxyTemplateCo
|
||||
const uploadsPath = `${context.appPublicPath}storage/uploads/`;
|
||||
const fileAccessPathMatcher = toCaddyPathMatcher(`${context.appPublicPath}files/`);
|
||||
const distPathMatcher = toCaddyPathMatcher(context.distPath);
|
||||
const settingsAssetsPathMatcher = toCaddyPathMatcher(`${context.appPublicPath}settings/assets/`);
|
||||
const settingsAssetsRoot = joinRuntimePath(context.distClientRoot, `${context.activeVersion}/settings/assets`);
|
||||
const settingsRoutePattern = `^${escapeRegExp(context.appPublicPath)}settings(?:/.*)?$`;
|
||||
const uploadsPathMatcher = toCaddyPathMatcher(uploadsPath);
|
||||
const apiPathMatcher = toCaddyPathMatcher(context.apiBasePath);
|
||||
const appPublicPathNoTrailingSlash = trimTrailingSlash(context.appPublicPath);
|
||||
@@ -1706,6 +1765,12 @@ function renderCaddyAppTemplate(siteAddress: string, context: EnvProxyTemplateCo
|
||||
' file_server',
|
||||
' }',
|
||||
'',
|
||||
` handle_path ${settingsAssetsPathMatcher} {`,
|
||||
` root * ${settingsAssetsRoot}`,
|
||||
' header Cache-Control "public, max-age=31536000, immutable"',
|
||||
' file_server',
|
||||
' }',
|
||||
'',
|
||||
' @oauth path_regexp oauth ^/\\.well-known/oauth-authorization-server/(.+)$',
|
||||
' handle @oauth {',
|
||||
' rewrite * /{re.oauth.1}/.well-known/oauth-authorization-server',
|
||||
@@ -1739,6 +1804,15 @@ function renderCaddyAppTemplate(siteAddress: string, context: EnvProxyTemplateCo
|
||||
` reverse_proxy ${context.proxyHost}:${context.apiPort}`,
|
||||
' }',
|
||||
'',
|
||||
` @settingsRoute path_regexp settingsRoute ${settingsRoutePattern}`,
|
||||
' handle @settingsRoute {',
|
||||
` root * ${publicDir}`,
|
||||
' header Cache-Control "no-store, no-cache, must-revalidate"',
|
||||
' header X-Robots-Tag "noindex, nofollow"',
|
||||
' try_files {path} /index-settings.html',
|
||||
' file_server',
|
||||
' }',
|
||||
'',
|
||||
' # Keep the v2 SPA route above the fallback SPA route.',
|
||||
` handle_path ${toCaddyPathMatcher(context.v2PublicPath)} {`,
|
||||
` root * ${publicDir}`,
|
||||
@@ -1834,6 +1908,7 @@ async function buildEnvProxyRenderState(
|
||||
: await mapProxyPathFromCliRoot(distClientRoot, options);
|
||||
const provider = resolveProxyProviderName(options?.provider);
|
||||
const templateContext = {
|
||||
activeVersion: runtimeVersion,
|
||||
appPublicPath: settings.appPublicPath,
|
||||
apiBasePath: settings.apiBasePath,
|
||||
apiPort,
|
||||
|
||||
@@ -7,13 +7,20 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { ManagedAppRuntime } from './app-runtime.js';
|
||||
import { resolveEnvKind, type EnvConfigEntry } from './auth-store.js';
|
||||
import { resolveConfiguredEnvPath } from './cli-home.js';
|
||||
import { resolveDockerEnvFileArg } from './docker-env-file.ts';
|
||||
import { resolveDockerEnvFileArg, resolveDockerEnvFilePath } from './docker-env-file.ts';
|
||||
import { resolveConfiguredAppPath } from './env-paths.js';
|
||||
|
||||
export const DEFAULT_MANAGED_ENV_FILE_VALUES = {
|
||||
APP_DISCOVERY_ADAPTER: 'local',
|
||||
APP_PROCESS_ADAPTER: 'local',
|
||||
APP_CLIENT_ENTRY_MODE: 'modern-only',
|
||||
} as const;
|
||||
|
||||
function trimValue(value: unknown): string | undefined {
|
||||
const text = String(value ?? '').trim();
|
||||
return text || undefined;
|
||||
@@ -86,6 +93,77 @@ export function resolveManagedLocalEnvFilePath(runtime: Extract<ManagedAppRuntim
|
||||
return normalizeEnvFilePath(path.join(runtime.projectRoot, '.env'));
|
||||
}
|
||||
|
||||
export function resolveManagedEnvFilePathFromConfig(
|
||||
envName: string,
|
||||
config?: Partial<EnvConfigEntry>,
|
||||
): string | undefined {
|
||||
const kind = config?.kind ?? resolveEnvKind(config);
|
||||
|
||||
if (kind === 'docker') {
|
||||
const filePath = resolveDockerEnvFilePath(envName, config);
|
||||
return filePath ? normalizeEnvFilePath(filePath) : undefined;
|
||||
}
|
||||
|
||||
if (kind !== 'local') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const explicitEnvFile = trimValue(config?.envFile);
|
||||
if (explicitEnvFile) {
|
||||
return normalizeEnvFilePath(resolveConfiguredEnvPath(explicitEnvFile) ?? explicitEnvFile);
|
||||
}
|
||||
|
||||
const configuredAppPath = resolveConfiguredAppPath(config);
|
||||
if (configuredAppPath) {
|
||||
return normalizeEnvFilePath(path.join(configuredAppPath, '.env'));
|
||||
}
|
||||
|
||||
const configuredAppRootPath = trimValue(config?.appRootPath);
|
||||
if (configuredAppRootPath) {
|
||||
const appRootPath = resolveConfiguredEnvPath(configuredAppRootPath) ?? configuredAppRootPath;
|
||||
return normalizeEnvFilePath(
|
||||
path.basename(appRootPath) === 'source' ? path.resolve(appRootPath, '..', '.env') : path.join(appRootPath, '.env'),
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function ensureManagedEnvFileDefaults(
|
||||
envName: string,
|
||||
config?: Partial<EnvConfigEntry>,
|
||||
defaults: Record<string, string> = DEFAULT_MANAGED_ENV_FILE_VALUES,
|
||||
): Promise<string | undefined> {
|
||||
const envFilePath = resolveManagedEnvFilePathFromConfig(envName, config);
|
||||
if (!envFilePath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let content = '';
|
||||
try {
|
||||
content = await readFile(envFilePath, 'utf8');
|
||||
} catch (error) {
|
||||
const code =
|
||||
error && typeof error === 'object' && 'code' in error ? String((error as { code?: unknown }).code) : '';
|
||||
if (code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const existing = parseSimpleEnvFile(content);
|
||||
const missingEntries = Object.entries(defaults).filter(([key, value]) => trimValue(value) && !existing[key]);
|
||||
if (missingEntries.length === 0) {
|
||||
return envFilePath;
|
||||
}
|
||||
|
||||
const separator = content && !content.endsWith('\n') ? '\n' : '';
|
||||
const nextContent = `${content}${separator}${missingEntries.map(([key, value]) => `${key}=${value}`).join('\n')}\n`;
|
||||
await mkdir(path.dirname(envFilePath), { recursive: true });
|
||||
await writeFile(envFilePath, nextContent, 'utf8');
|
||||
|
||||
return envFilePath;
|
||||
}
|
||||
|
||||
export async function resolveManagedRuntimeEnvFilePath(
|
||||
runtime: Extract<ManagedAppRuntime, { kind: 'local' | 'docker' }>,
|
||||
): Promise<string | undefined> {
|
||||
|
||||
@@ -153,6 +153,7 @@ export async function writeCaddyProxyBundle(
|
||||
writeFile(bundle.appConfigPath, nextAppConfigContent, 'utf8'),
|
||||
writeFile(bundle.indexV1Path, bundle.indexV1Content, 'utf8'),
|
||||
writeFile(bundle.indexV2Path, bundle.indexV2Content, 'utf8'),
|
||||
writeFile(bundle.indexSettingsPath, bundle.indexSettingsContent, 'utf8'),
|
||||
writeFile(bundle.mainConfigPath, bundle.mainConfigContent, 'utf8'),
|
||||
]);
|
||||
|
||||
@@ -182,6 +183,7 @@ export async function writeManualCaddyProxyBundle(
|
||||
writeFile(bundle.appConfigPath, nextAppConfigContent, 'utf8'),
|
||||
writeFile(bundle.indexV1Path, bundle.indexV1Content, 'utf8'),
|
||||
writeFile(bundle.indexV2Path, bundle.indexV2Content, 'utf8'),
|
||||
writeFile(bundle.indexSettingsPath, bundle.indexSettingsContent, 'utf8'),
|
||||
writeFile(bundle.mainConfigPath, bundle.mainConfigContent, 'utf8'),
|
||||
]);
|
||||
|
||||
|
||||
@@ -209,6 +209,7 @@ async function writeResolvedNginxProxyBundle(
|
||||
writeFile(bundle.appConfigPath, nextAppConfigContent, 'utf8'),
|
||||
writeFile(bundle.indexV1Path, bundle.indexV1Content, 'utf8'),
|
||||
writeFile(bundle.indexV2Path, bundle.indexV2Content, 'utf8'),
|
||||
writeFile(bundle.indexSettingsPath, bundle.indexSettingsContent, 'utf8'),
|
||||
writeFile(bundle.mainConfigPath, bundle.mainConfigContent, 'utf8'),
|
||||
syncEnvProxyNginxSnippets(),
|
||||
]);
|
||||
|
||||
@@ -17,12 +17,18 @@ import {
|
||||
|
||||
describe('auth redirect helpers', () => {
|
||||
const originalLocation = globalThis.window.location;
|
||||
const originalModernClientPrefix = window.__nocobase_modern_client_prefix__;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis.window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
if (originalModernClientPrefix === undefined) {
|
||||
delete window.__nocobase_modern_client_prefix__;
|
||||
} else {
|
||||
window.__nocobase_modern_client_prefix__ = originalModernClientPrefix;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -84,6 +90,107 @@ describe('auth redirect helpers', () => {
|
||||
expect(buildV2SigninHref(app, '/v2/admin/7vu4c2sdk6h')).toBe('/v2/signin?redirect=%2Fv2%2Fadmin%2F7vu4c2sdk6h');
|
||||
});
|
||||
|
||||
it('should keep validated standalone settings redirects outside the v2 basename', () => {
|
||||
const app = {
|
||||
getPublicPath: () => '/nocobase/v2/',
|
||||
router: {
|
||||
getBasename: () => '/nocobase/v2',
|
||||
},
|
||||
} as any;
|
||||
|
||||
expect(normalizeV2RedirectPath(app, '/nocobase/settings/workflow?tab=list#recent')).toBe(
|
||||
'/nocobase/settings/workflow?tab=list#recent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should build the standalone settings signin URL for a standalone settings app', () => {
|
||||
const app = {
|
||||
name: 'main',
|
||||
getPublicPath: () => '/nocobase/',
|
||||
router: {
|
||||
getBasename: () => '/nocobase',
|
||||
},
|
||||
pluginSettingsManager: {
|
||||
getRouteName: () => 'settings.',
|
||||
getRoutePath: () => '/settings/',
|
||||
},
|
||||
} satisfies Parameters<typeof buildV2SigninHref>[0];
|
||||
expect(buildV2SigninHref(app, '/nocobase/settings/workflow?tab=list#recent')).toBe(
|
||||
'/nocobase/settings/signin?redirect=%2Fnocobase%2Fsettings%2Fworkflow%3Ftab%3Dlist%23recent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep an apps segment inside the main public path out of the Settings application scope', () => {
|
||||
const app = {
|
||||
name: 'main',
|
||||
getPublicPath: () => '/tenant/apps/root/',
|
||||
router: {
|
||||
getBasename: () => '/tenant/apps/root/',
|
||||
},
|
||||
pluginSettingsManager: {
|
||||
getRouteName: () => 'settings.',
|
||||
getRoutePath: () => '/settings/',
|
||||
},
|
||||
} satisfies Parameters<typeof buildV2SigninHref>[0];
|
||||
|
||||
expect(buildV2SigninHref(app, '/tenant/apps/root/settings/workflow')).toBe(
|
||||
'/tenant/apps/root/settings/signin?redirect=%2Ftenant%2Fapps%2Froot%2Fsettings%2Fworkflow',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/nocobase/settings/apps/test-app/', '/nocobase/settings/apps/test-app/signin'],
|
||||
['/nocobase/settings/_app/test-app/', '/nocobase/settings/_app/test-app/signin'],
|
||||
])('should keep the standalone settings signin in the current sub-app scope: %s', (basename, signinPath) => {
|
||||
const app = {
|
||||
name: 'test-app',
|
||||
getPublicPath: () => '/nocobase/',
|
||||
router: {
|
||||
getBasename: () => basename,
|
||||
},
|
||||
pluginSettingsManager: {
|
||||
getRouteName: () => 'settings.',
|
||||
getRoutePath: () => '/',
|
||||
},
|
||||
} as any;
|
||||
|
||||
expect(buildV2SigninHref(app, `${basename}workflow`)).toBe(
|
||||
`${signinPath}?redirect=${encodeURIComponent(`${basename}workflow`)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept the current standalone settings signin URL only', () => {
|
||||
Object.defineProperty(globalThis.window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
...originalLocation,
|
||||
origin: 'http://localhost:20000',
|
||||
},
|
||||
});
|
||||
const app = {
|
||||
name: 'test-app',
|
||||
getPublicPath: () => '/nocobase/',
|
||||
router: {
|
||||
getBasename: () => '/nocobase/settings/apps/test-app/',
|
||||
},
|
||||
pluginSettingsManager: {
|
||||
getRouteName: () => 'settings.',
|
||||
getRoutePath: () => '/',
|
||||
},
|
||||
} as any;
|
||||
|
||||
expect(
|
||||
resolveV2SigninRedirect(
|
||||
'/nocobase/settings/apps/test-app/signin?redirect=%2Fnocobase%2Fsettings%2Fapps%2Ftest-app',
|
||||
app,
|
||||
),
|
||||
).toBe(
|
||||
'http://localhost:20000/nocobase/settings/apps/test-app/signin?redirect=%2Fnocobase%2Fsettings%2Fapps%2Ftest-app',
|
||||
);
|
||||
expect(resolveV2SigninRedirect('/nocobase/v/apps/test-app/signin', app)).toBeNull();
|
||||
expect(resolveV2SigninRedirect('/nocobase/settings/apps/other-app/signin', app)).toBeNull();
|
||||
});
|
||||
|
||||
it('should redirect with window.location.replace by default', () => {
|
||||
const replace = vi.fn();
|
||||
Object.defineProperty(globalThis.window, 'location', {
|
||||
@@ -134,6 +241,36 @@ describe('auth redirect helpers', () => {
|
||||
});
|
||||
|
||||
describe('v2 sub-app context (router basename contains /apps/<id>/)', () => {
|
||||
it('should keep only the current sub-app standalone settings redirect', () => {
|
||||
const app = {
|
||||
getPublicPath: () => '/nocobase/v2/',
|
||||
router: {
|
||||
getBasename: () => '/nocobase/v2/apps/test-app/',
|
||||
},
|
||||
} as any;
|
||||
|
||||
expect(normalizeV2RedirectPath(app, '/nocobase/settings/apps/test-app/workflow')).toBe(
|
||||
'/nocobase/settings/apps/test-app/workflow',
|
||||
);
|
||||
expect(normalizeV2RedirectPath(app, '/nocobase/settings/apps/other-app/workflow')).toBe(
|
||||
'/nocobase/v2/apps/test-app/admin/',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/nocobase/settings/apps/test-app/../../other-app/settings',
|
||||
'/nocobase/settings/apps/test-app/%2e%2e/%2E%2e/other-app/settings',
|
||||
])('should reject a standalone settings redirect that resolves outside the current sub-app: %s', (target) => {
|
||||
const app = {
|
||||
getPublicPath: () => '/nocobase/v2/',
|
||||
router: {
|
||||
getBasename: () => '/nocobase/v2/apps/test-app/',
|
||||
},
|
||||
} as any;
|
||||
|
||||
expect(normalizeV2RedirectPath(app, target)).toBe('/nocobase/v2/apps/test-app/admin/');
|
||||
});
|
||||
|
||||
it('should normalize signin redirect fallback under the current sub-app basename', () => {
|
||||
const app = {
|
||||
getPublicPath: () => '/v/',
|
||||
|
||||
@@ -15,8 +15,11 @@ import { fileURLToPath } from 'node:url';
|
||||
type BrowserCheckerCase = {
|
||||
pathname: string;
|
||||
publicPath: string;
|
||||
search?: string;
|
||||
hash?: string;
|
||||
modernClientPrefix?: string;
|
||||
appClientEntryMode?: string;
|
||||
scriptSrc?: string;
|
||||
expectedRedirect?: string;
|
||||
};
|
||||
|
||||
@@ -47,8 +50,8 @@ function executeBrowserChecker(scriptPath: string, input: BrowserCheckerCase) {
|
||||
location: {
|
||||
origin: 'http://c.local.nocobase.com',
|
||||
pathname: input.pathname,
|
||||
search: '',
|
||||
hash: '',
|
||||
search: input.search || '',
|
||||
hash: input.hash || '',
|
||||
replace,
|
||||
},
|
||||
console: consoleMock,
|
||||
@@ -58,6 +61,13 @@ function executeBrowserChecker(scriptPath: string, input: BrowserCheckerCase) {
|
||||
onresize: undefined,
|
||||
},
|
||||
document: {
|
||||
currentScript: {
|
||||
src:
|
||||
input.scriptSrc ||
|
||||
(scriptPath.includes('/app/client-v2/public/')
|
||||
? 'http://assets.local.nocobase.com/v/browser-checker.js?v=1'
|
||||
: 'http://assets.local.nocobase.com/browser-checker.js?v=1'),
|
||||
},
|
||||
documentElement: {
|
||||
className: '',
|
||||
clientWidth: 1280,
|
||||
@@ -80,11 +90,11 @@ function executeBrowserChecker(scriptPath: string, input: BrowserCheckerCase) {
|
||||
describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
|
||||
it('normalizes a relative public path before redirecting to the trailing-slash entry', () => {
|
||||
const replace = executeBrowserChecker(scriptPath, {
|
||||
pathname: '/nocobase/v',
|
||||
publicPath: 'nocobase/v/',
|
||||
pathname: '/nocobase/console',
|
||||
publicPath: 'nocobase/console/',
|
||||
});
|
||||
|
||||
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/v/');
|
||||
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/console/');
|
||||
});
|
||||
|
||||
it('prefixes outside paths with a root-relative basename instead of duplicating a relative segment', () => {
|
||||
@@ -98,7 +108,7 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
|
||||
|
||||
it('does not redirect when the current path is already under the normalized basename', () => {
|
||||
const replace = executeBrowserChecker(scriptPath, {
|
||||
pathname: '/nocobase/v/',
|
||||
pathname: '/nocobase/v/admin',
|
||||
publicPath: 'nocobase/v/',
|
||||
});
|
||||
|
||||
@@ -170,4 +180,112 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
|
||||
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/v/apps/a_31itq60q4kg/admin');
|
||||
});
|
||||
}
|
||||
|
||||
if (scriptPath.includes('/app/client-v2/public/')) {
|
||||
it.each(['/v', '/v/'])('redirects the main modern root %s to Settings', (pathname) => {
|
||||
const replace = executeBrowserChecker(scriptPath, {
|
||||
pathname,
|
||||
publicPath: '/v/',
|
||||
modernClientPrefix: 'v',
|
||||
});
|
||||
|
||||
expect(replace).toHaveBeenCalledOnce();
|
||||
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/settings');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/v/apps/demo', '/settings/apps/demo'],
|
||||
['/v/apps/demo/', '/settings/apps/demo'],
|
||||
['/v/_app/demo', '/settings/_app/demo'],
|
||||
['/v/_app/demo/', '/settings/_app/demo'],
|
||||
])('redirects the scoped modern root %s to %s', (pathname, expectedPathname) => {
|
||||
const replace = executeBrowserChecker(scriptPath, {
|
||||
pathname,
|
||||
publicPath: '/v/',
|
||||
modernClientPrefix: 'v',
|
||||
});
|
||||
|
||||
expect(replace).toHaveBeenCalledOnce();
|
||||
expect(replace).toHaveBeenCalledWith(`http://c.local.nocobase.com${expectedPathname}`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/settings/apps/demo', '/', '/settings/apps/demo/', 'v'],
|
||||
['/settings/_app/demo', '/', '/settings/_app/demo/', 'v'],
|
||||
['/nocobase/settings/apps/demo', '/nocobase/', '/nocobase/settings/apps/demo/', 'v'],
|
||||
['/nocobase/settings/_app/demo', '/nocobase/', '/nocobase/settings/_app/demo/', 'v'],
|
||||
['/tenant/apps/root/settings/apps/demo', '/tenant/apps/root/', '/tenant/apps/root/settings/apps/demo/', 'v'],
|
||||
['/tenant/_app/root/settings/_app/demo', '/tenant/_app/root/', '/tenant/_app/root/settings/_app/demo/', 'v'],
|
||||
['/tenant/v/settings/apps/demo', '/tenant/v/', '/tenant/v/settings/apps/demo/', 'v'],
|
||||
['/tenant/modern/settings/_app/demo', '/tenant/modern/', '/tenant/modern/settings/_app/demo/', 'modern'],
|
||||
])('normalizes the scoped Settings root %s to %s', (pathname, publicPath, expectedPathname, modernClientPrefix) => {
|
||||
const replace = executeBrowserChecker(scriptPath, {
|
||||
pathname,
|
||||
publicPath,
|
||||
modernClientPrefix,
|
||||
scriptSrc: 'https://cdn.example.com/ui/settings/browser-checker.js?v=1',
|
||||
search: '?tab=overview',
|
||||
hash: '#panel',
|
||||
});
|
||||
|
||||
expect(replace).toHaveBeenCalledOnce();
|
||||
expect(replace).toHaveBeenCalledWith(`http://c.local.nocobase.com${expectedPathname}?tab=overview#panel`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/tenant/apps/root/modern/apps/demo/', '/tenant/apps/root/modern/', '/tenant/apps/root/settings/apps/demo'],
|
||||
['/tenant/_app/root/modern/_app/demo/', '/tenant/_app/root/modern/', '/tenant/_app/root/settings/_app/demo'],
|
||||
])(
|
||||
'preserves public path, query, and hash for %s without treating public-path segments as app scope',
|
||||
(pathname, publicPath, expectedPathname) => {
|
||||
const replace = executeBrowserChecker(scriptPath, {
|
||||
pathname,
|
||||
publicPath,
|
||||
modernClientPrefix: 'modern',
|
||||
search: '?tab=overview',
|
||||
hash: '#panel',
|
||||
});
|
||||
|
||||
expect(replace).toHaveBeenCalledOnce();
|
||||
expect(replace).toHaveBeenCalledWith(`http://c.local.nocobase.com${expectedPathname}?tab=overview#panel`);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['/v/admin', '/v/'],
|
||||
['/v/apps/demo/admin', '/v/'],
|
||||
['/v/_app/demo/admin', '/v/'],
|
||||
['/v/_apps/demo', '/v/'],
|
||||
['/v/settings/apps/demo', '/v/'],
|
||||
['/nocobase/modern/settings/_app/demo', '/nocobase/modern/', 'modern'],
|
||||
])('does not redirect a non-root modern path %s to Settings', (pathname, publicPath, modernClientPrefix = 'v') => {
|
||||
const replace = executeBrowserChecker(scriptPath, {
|
||||
pathname,
|
||||
publicPath,
|
||||
modernClientPrefix,
|
||||
});
|
||||
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/settings', '/'],
|
||||
['/settings/', '/'],
|
||||
['/settings/apps/demo/', '/'],
|
||||
['/settings/_app/demo/', '/'],
|
||||
['/settings/apps/demo/multi-portal', '/'],
|
||||
['/settings/_app/demo/multi-portal', '/'],
|
||||
['/settings/_apps/demo', '/'],
|
||||
['/nocobase/settings/apps/demo/multi-portal', '/nocobase/'],
|
||||
])('does not redirect a non-target Settings path %s', (pathname, publicPath) => {
|
||||
const replace = executeBrowserChecker(scriptPath, {
|
||||
pathname,
|
||||
publicPath,
|
||||
modernClientPrefix: 'v',
|
||||
scriptSrc: 'https://cdn.example.com/ui/settings/browser-checker.js?v=1',
|
||||
});
|
||||
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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 MockAdapter from 'axios-mock-adapter';
|
||||
import type { ApplicationOptions } from '../Application';
|
||||
import { SettingsApplication } from '../settings-app/SettingsApplication';
|
||||
|
||||
class MockSettingsApplication extends SettingsApplication {
|
||||
readonly apiMock: MockAdapter;
|
||||
|
||||
constructor(options: ApplicationOptions = {}) {
|
||||
super({
|
||||
router: { type: 'memory', initialEntries: ['/settings'] },
|
||||
ws: false,
|
||||
...options,
|
||||
});
|
||||
this.apiMock = new MockAdapter(this.apiClient.axios);
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockSettingsClient(options?: ApplicationOptions) {
|
||||
return new MockSettingsApplication(options);
|
||||
}
|
||||
@@ -51,6 +51,27 @@ class AuthBootstrapRoutePlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
class TestAdminPortalPlugin extends Plugin {
|
||||
async load() {
|
||||
this.app.layoutManager.registerLayout({
|
||||
routeName: 'admin',
|
||||
routePath: '/admin',
|
||||
uid: 'admin-layout-model',
|
||||
layoutModelClass: 'AdminLayoutModel',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class TestRootPortalPlugin extends Plugin {
|
||||
async load() {
|
||||
this.router.add('root', {
|
||||
path: '/',
|
||||
authCheck: true,
|
||||
Component: () => <div>portal landing</div>,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('nocobase buildin plugin auth redirect', () => {
|
||||
const originalLocation = globalThis.window.location;
|
||||
|
||||
@@ -85,6 +106,16 @@ describe('nocobase buildin plugin auth redirect', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should leave Admin portal registration to runtime plugins', async () => {
|
||||
const app = createMockClient();
|
||||
const plugin = new NocoBaseBuildInPlugin({}, app);
|
||||
|
||||
await plugin.load();
|
||||
|
||||
expect(app.layoutManager.hasLayout('admin')).toBe(false);
|
||||
expect(app.router.has('root')).toBe(false);
|
||||
});
|
||||
|
||||
it('should navigate to v2 signin when /auth:check returns no user', async () => {
|
||||
// Aligns with v1: use react-router navigate (virtual) rather than
|
||||
// `window.location.replace`, so a `window.location.href` queued elsewhere
|
||||
@@ -92,7 +123,7 @@ describe('nocobase buildin plugin auth redirect', () => {
|
||||
// overridden.
|
||||
const app = createMockClient({
|
||||
publicPath: '/v2/',
|
||||
plugins: [NocoBaseBuildInPlugin as any],
|
||||
plugins: [NocoBaseBuildInPlugin as any, TestAdminPortalPlugin as typeof Plugin],
|
||||
router: { type: 'memory', initialEntries: ['/v2/admin/7vu4c2sdk6h'] },
|
||||
});
|
||||
app.apiMock.onGet('app:getLang').reply(200, {
|
||||
@@ -115,7 +146,7 @@ describe('nocobase buildin plugin auth redirect', () => {
|
||||
// and race the 2FA response interceptor with its own signin redirect.
|
||||
const app = createMockClient({
|
||||
publicPath: '/v2/',
|
||||
plugins: [NocoBaseBuildInPlugin as any],
|
||||
plugins: [NocoBaseBuildInPlugin as any, TestAdminPortalPlugin as typeof Plugin],
|
||||
router: { type: 'memory', initialEntries: ['/v2/admin'] },
|
||||
});
|
||||
app.apiClient.auth.setToken('test-token');
|
||||
@@ -136,29 +167,34 @@ describe('nocobase buildin plugin auth redirect', () => {
|
||||
expect(app.apiMock.history.post.filter((request) => request.url === 'auth:syncCookies')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should redirect unauthenticated v2 root access to v2 signin via <Navigate />', async () => {
|
||||
it('should redirect an unauthenticated Portal root to v2 signin', async () => {
|
||||
const app = createMockClient({
|
||||
publicPath: '/nocobase/v2/',
|
||||
plugins: [NocoBaseBuildInPlugin as any],
|
||||
plugins: [NocoBaseBuildInPlugin as any, TestRootPortalPlugin as typeof Plugin],
|
||||
router: { type: 'memory', initialEntries: ['/nocobase/v2/'] },
|
||||
});
|
||||
app.apiMock.onGet('app:getLang').reply(200, {
|
||||
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
|
||||
});
|
||||
app.apiMock.onGet('/auth:check').reply(200, { data: {} });
|
||||
|
||||
const Root = app.getRootComponent();
|
||||
render(<Root />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(app.router.router.state.location.pathname).toBe('/nocobase/v2/signin');
|
||||
expect(app.router.router.state.location.search).toBe('?redirect=%2Fnocobase%2Fv2%2Fadmin');
|
||||
expect(app.router.router.state.location.search).toBe('?redirect=%2Fnocobase%2Fv2');
|
||||
});
|
||||
});
|
||||
|
||||
it('should check current user after navigating from skipped route to v2 admin', async () => {
|
||||
const app = createMockClient({
|
||||
publicPath: '/v2/',
|
||||
plugins: [NocoBaseBuildInPlugin as any, SkippedPublicRoutePlugin as any],
|
||||
plugins: [
|
||||
NocoBaseBuildInPlugin as any,
|
||||
TestAdminPortalPlugin as typeof Plugin,
|
||||
SkippedPublicRoutePlugin as typeof Plugin,
|
||||
],
|
||||
router: { type: 'memory', initialEntries: ['/v2/public'] },
|
||||
});
|
||||
app.apiMock.onGet('app:getLang').reply(200, {
|
||||
@@ -385,7 +421,7 @@ describe('nocobase buildin plugin auth redirect', () => {
|
||||
it('should render v2 admin root without redirecting away', async () => {
|
||||
const app = createMockClient({
|
||||
publicPath: '/v2/',
|
||||
plugins: [NocoBaseBuildInPlugin as any],
|
||||
plugins: [NocoBaseBuildInPlugin as any, TestAdminPortalPlugin as typeof Plugin],
|
||||
router: { type: 'memory', initialEntries: ['/v2/admin'] },
|
||||
});
|
||||
app.apiMock.onGet('app:getLang').reply(200, {
|
||||
@@ -419,7 +455,7 @@ describe('nocobase buildin plugin auth redirect', () => {
|
||||
async (pathname) => {
|
||||
const app = createMockClient({
|
||||
publicPath: '/v2/',
|
||||
plugins: [NocoBaseBuildInPlugin as any],
|
||||
plugins: [NocoBaseBuildInPlugin as any, TestAdminPortalPlugin as typeof Plugin],
|
||||
router: { type: 'memory', initialEntries: [pathname] },
|
||||
});
|
||||
app.apiMock.onGet('app:getLang').reply(200, {
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { ACLRolesCheckProvider, createMockClient, Plugin } from '@nocobase/client-v2';
|
||||
import { ACLRolesCheckProvider, Plugin } from '@nocobase/client-v2';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { NocoBaseBuildInPlugin } from '../nocobase-buildin-plugin';
|
||||
import { SettingsBuildInPlugin } from '../settings-app/SettingsBuildInPlugin';
|
||||
import { createMockSettingsClient } from './mockSettingsApplication';
|
||||
|
||||
class TestAclPlugin extends Plugin {
|
||||
async load() {
|
||||
@@ -31,7 +32,7 @@ class TestSettingsLinkPlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
type MockClientApplication = ReturnType<typeof createMockClient>;
|
||||
type MockClientApplication = ReturnType<typeof createMockSettingsClient>;
|
||||
|
||||
const renderApp = (app: MockClientApplication) => {
|
||||
const Root = app.getRootComponent();
|
||||
@@ -49,9 +50,9 @@ const waitForGetRequests = async (app: MockClientApplication, urls: string[]) =>
|
||||
};
|
||||
|
||||
const setupApp = (pmList: any[], plugins: Array<typeof Plugin> = []) => {
|
||||
const app = createMockClient({
|
||||
plugins: [NocoBaseBuildInPlugin, TestAclPlugin, ...plugins],
|
||||
router: { type: 'memory', initialEntries: ['/admin/settings/plugin-manager'] },
|
||||
const app = createMockSettingsClient({
|
||||
plugins: [SettingsBuildInPlugin, TestAclPlugin, ...plugins],
|
||||
router: { type: 'memory', initialEntries: ['/settings/plugin-manager'] },
|
||||
});
|
||||
|
||||
app.apiMock.onGet('/auth:check').reply(200, {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 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 MockAdapter from 'axios-mock-adapter';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Application } from '../index';
|
||||
import { SettingsApplication } from '../settings-app/SettingsApplication';
|
||||
import { SettingsBuildInPlugin } from '../settings-app/SettingsBuildInPlugin';
|
||||
|
||||
describe('SettingsApplication', () => {
|
||||
it('uses the standalone settings route namespace', () => {
|
||||
const adminApp = new Application({ router: { type: 'memory' }, ws: false });
|
||||
const settingsApp = new SettingsApplication({ router: { type: 'memory' }, ws: false });
|
||||
|
||||
expect(adminApp.pluginSettingsManager.getRouteName('demo.index')).toBe('admin.settings.demo.index');
|
||||
expect(adminApp.pluginSettingsManager.getRoutePath('demo.index')).toBe('/admin/settings/demo');
|
||||
|
||||
expect(settingsApp.pluginSettingsManager.getRouteName('demo.index')).toBe('settings.demo.index');
|
||||
expect(settingsApp.pluginSettingsManager.getRoutePath('demo.index')).toBe('/settings/demo');
|
||||
expect(settingsApp.pluginSettingsManager.getRoutePath('')).toBe('/settings/');
|
||||
});
|
||||
|
||||
it.each(['apps', '_app'])('uses basename-relative paths for the %s sub-application Settings runtime', (scope) => {
|
||||
const app = new SettingsApplication({
|
||||
publicPath: '/nocobase/',
|
||||
router: { type: 'memory', basename: `/nocobase/settings/${scope}/demo/` },
|
||||
ws: false,
|
||||
});
|
||||
|
||||
expect(app.pluginSettingsManager.getRouteName('demo.index')).toBe('settings.demo.index');
|
||||
expect(app.pluginSettingsManager.getRoutePath('')).toBe('/');
|
||||
expect(app.pluginSettingsManager.getRoutePath('demo.index')).toBe('/demo');
|
||||
|
||||
app.router.add('settings', { path: '/settings' });
|
||||
app.router.add('settingsDetails.workflow.canvas', { path: '/settings/workflow/workflows/:id' });
|
||||
app.router.add('settingsDetails.preview', { path: '/settings-preview/:id' });
|
||||
app.router.add('auth.signin', { path: '/signin' });
|
||||
|
||||
expect(app.router.get('settings')).toMatchObject({ path: '/' });
|
||||
expect(app.router.get('settingsDetails.workflow.canvas')).toMatchObject({
|
||||
path: '/workflow/workflows/:id',
|
||||
authCheck: true,
|
||||
});
|
||||
expect(
|
||||
app.router
|
||||
.matchRoutes(`/nocobase/settings/${scope}/demo/workflow/workflows/1`)
|
||||
?.some((match) => match.route.id === 'settingsDetails.workflow.canvas'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
app.router
|
||||
.matchRoutes(`/nocobase/settings/${scope}/demo/settings/workflow/workflows/1`)
|
||||
?.some((match) => match.route.id === 'settingsDetails.workflow.canvas'),
|
||||
).toBe(false);
|
||||
expect(app.router.get('settingsDetails.preview')).toMatchObject({ path: '/settings-preview/:id' });
|
||||
expect(app.router.get('auth.signin')).toMatchObject({ path: '/signin' });
|
||||
});
|
||||
|
||||
it('does not treat an apps segment inside the main application public path as a sub-application scope', () => {
|
||||
const app = new SettingsApplication({
|
||||
router: { type: 'memory', basename: '/tenant/apps/root/' },
|
||||
ws: false,
|
||||
});
|
||||
|
||||
expect(app.pluginSettingsManager.getRoutePath('')).toBe('/settings/');
|
||||
expect(app.pluginSettingsManager.getRoutePath('demo.index')).toBe('/settings/demo');
|
||||
|
||||
app.router.add('settingsDetails.workflow.canvas', { path: '/settings/workflow/workflows/:id' });
|
||||
expect(app.router.get('settingsDetails.workflow.canvas')).toMatchObject({
|
||||
path: '/settings/workflow/workflows/:id',
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['apps', '_app'])(
|
||||
'does not treat a scoped-looking %s suffix inside the main public path as a sub-application scope',
|
||||
(scope) => {
|
||||
const publicPath = `/tenant/settings/${scope}/root/`;
|
||||
const app = new SettingsApplication({
|
||||
publicPath,
|
||||
router: { type: 'memory', basename: publicPath },
|
||||
ws: false,
|
||||
});
|
||||
|
||||
expect(app.pluginSettingsManager.getRoutePath('')).toBe('/settings/');
|
||||
expect(app.pluginSettingsManager.getRoutePath('demo.index')).toBe('/settings/demo');
|
||||
|
||||
app.router.add('settingsDetails.workflow.canvas', { path: '/settings/workflow/workflows/:id' });
|
||||
app.router.add('auth.signin', { path: '/signin' });
|
||||
|
||||
expect(app.router.get('settingsDetails.workflow.canvas')).toMatchObject({
|
||||
path: '/settings/workflow/workflows/:id',
|
||||
});
|
||||
expect(app.router.get('auth.signin')).toMatchObject({ path: '/settings/signin' });
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps only routes owned by the settings runtime', () => {
|
||||
const app = new SettingsApplication({ router: { type: 'memory' }, ws: false });
|
||||
|
||||
app.router.add('settings', { path: '/settings' });
|
||||
app.router.add('settings.demo', { path: 'demo' });
|
||||
app.router.add('settingsDetails.workflow.canvas', { path: '/settings/workflow/workflows/:id' });
|
||||
app.router.add('admin.demo', { path: '/admin/demo' });
|
||||
app.router.add('public-forms', { path: '/public-forms/:key' });
|
||||
app.router.add('mobile', { path: '/mobile' });
|
||||
|
||||
expect(app.router.has('settings')).toBe(true);
|
||||
expect(app.router.has('settings.demo')).toBe(true);
|
||||
expect(app.router.get('settingsDetails.workflow.canvas')).toMatchObject({
|
||||
path: '/settings/workflow/workflows/:id',
|
||||
authCheck: true,
|
||||
});
|
||||
expect(app.router.has('admin.demo')).toBe(false);
|
||||
expect(app.router.has('public-forms')).toBe(false);
|
||||
expect(app.router.has('mobile')).toBe(false);
|
||||
expect(app.router.has('not-found')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps hidden plugin detail pages in the normal Settings route tree', () => {
|
||||
const app = new SettingsApplication({ router: { type: 'memory' }, ws: false });
|
||||
|
||||
app.pluginSettingsManager.addMenuItem({ key: 'public-forms', title: 'Public forms' });
|
||||
app.pluginSettingsManager.addPageTabItem({
|
||||
menuKey: 'public-forms',
|
||||
key: ':name',
|
||||
title: false,
|
||||
hidden: true,
|
||||
Component: () => null,
|
||||
});
|
||||
|
||||
expect(app.pluginSettingsManager.get('public-forms.:name')).toMatchObject({
|
||||
hidden: true,
|
||||
path: '/settings/public-forms/:name',
|
||||
});
|
||||
expect(app.router.get('settings.public-forms.:name')).toMatchObject({ path: ':name' });
|
||||
});
|
||||
|
||||
it('registers the authenticated settings shell without the admin layout', async () => {
|
||||
const app = new SettingsApplication({ router: { type: 'memory' }, ws: false });
|
||||
const apiMock = new MockAdapter(app.apiClient.axios);
|
||||
apiMock.onGet('app:getLang').reply(200, { data: { lang: 'en-US', resources: { client: {} }, cron: {} } });
|
||||
const plugin = new SettingsBuildInPlugin({ name: 'settings-buildin' }, app);
|
||||
|
||||
await plugin.afterAdd();
|
||||
await plugin.load();
|
||||
app.router.add('settingsDetails.workflow.canvas', { path: '/settings/workflow/workflows/:id' });
|
||||
|
||||
expect(app.router.get('settings')).toMatchObject({ path: '/settings', authCheck: true });
|
||||
expect(app.router.get('settingsDetails')).toMatchObject({ path: '/settings', authCheck: true });
|
||||
expect(
|
||||
app.router.matchRoutes('/settings/workflow/workflows/1')?.some((match) => match.route.authCheck === true),
|
||||
).toBe(true);
|
||||
expect(app.router.has('admin')).toBe(false);
|
||||
expect(app.layoutManager.hasLayout('admin')).toBe(false);
|
||||
expect(app.pluginSettingsManager.has('plugin-manager')).toBe(true);
|
||||
expect(app.pluginSettingsManager.has('system-settings')).toBe(true);
|
||||
expect(app.pluginSettingsManager.has('security')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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 { Application } from '../index';
|
||||
import { SettingsApplication } from '../settings-app/SettingsApplication';
|
||||
|
||||
describe('standalone settings authentication routes', () => {
|
||||
it('keeps and rebases the auth and 2fa route families', () => {
|
||||
const app = new SettingsApplication({ router: { type: 'memory' }, ws: false });
|
||||
|
||||
app.router.add('auth', { Component: () => null });
|
||||
app.router.add('auth.signin', { path: '/signin', skipAuthCheck: true, Component: () => null });
|
||||
app.router.add('auth.signup', { path: '/signup', skipAuthCheck: true, Component: () => null });
|
||||
app.router.add('auth.forgotPassword', {
|
||||
path: '/forgot-password',
|
||||
skipAuthCheck: true,
|
||||
Component: () => null,
|
||||
});
|
||||
app.router.add('auth.resetPassword', {
|
||||
path: '/reset-password',
|
||||
skipAuthCheck: true,
|
||||
Component: () => null,
|
||||
});
|
||||
app.router.add('2fa', { Component: () => null });
|
||||
app.router.add('2fa.verify', { path: '/2fa', skipAuthCheck: true, Component: () => null });
|
||||
|
||||
expect(app.router.get('auth.signin')).toMatchObject({ path: '/settings/signin', skipAuthCheck: true });
|
||||
expect(app.router.get('auth.signup')).toMatchObject({ path: '/settings/signup', skipAuthCheck: true });
|
||||
expect(app.router.get('auth.forgotPassword')).toMatchObject({
|
||||
path: '/settings/forgot-password',
|
||||
skipAuthCheck: true,
|
||||
});
|
||||
expect(app.router.get('auth.resetPassword')).toMatchObject({
|
||||
path: '/settings/reset-password',
|
||||
skipAuthCheck: true,
|
||||
});
|
||||
expect(app.router.get('2fa.verify')).toMatchObject({ path: '/settings/2fa', skipAuthCheck: true });
|
||||
expect(app.router.matchRoutes('/settings/signin')?.map((match) => match.route.id)).toContain('auth.signin');
|
||||
expect(app.router.isSkippedAuthCheckRoute('/settings/reset-password')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not change the default Client V2 authentication routes', () => {
|
||||
const app = new Application({ router: { type: 'memory' }, ws: false });
|
||||
|
||||
app.router.add('auth', { Component: () => null });
|
||||
app.router.add('auth.signin', { path: '/signin', skipAuthCheck: true, Component: () => null });
|
||||
app.router.add('2fa', { Component: () => null });
|
||||
app.router.add('2fa.verify', { path: '/2fa', skipAuthCheck: true, Component: () => null });
|
||||
|
||||
expect(app.router.get('auth.signin')).toMatchObject({ path: '/signin', skipAuthCheck: true });
|
||||
expect(app.router.get('2fa.verify')).toMatchObject({ path: '/2fa', skipAuthCheck: true });
|
||||
});
|
||||
|
||||
it('continues to reject unrelated routes from the shared plugin lane', () => {
|
||||
const app = new SettingsApplication({ router: { type: 'memory' }, ws: false });
|
||||
|
||||
app.router.add('admin.demo', { path: '/admin/demo' });
|
||||
app.router.add('public.demo', { path: '/public/demo' });
|
||||
app.router.add('mobile.demo', { path: '/mobile/demo' });
|
||||
|
||||
expect(app.router.has('admin.demo')).toBe(false);
|
||||
expect(app.router.has('public.demo')).toBe(false);
|
||||
expect(app.router.has('mobile.demo')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -7,14 +7,15 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { ACLRolesCheckProvider, createMockClient, Plugin } from '@nocobase/client-v2';
|
||||
import { ACLRolesCheckProvider, Plugin } from '@nocobase/client-v2';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { message } from 'antd';
|
||||
import { AdminSettingsLayoutModel as ClientV2AdminSettingsLayoutModel } from '../settings-center';
|
||||
import { AdminSettingsLayoutModel as ClientV1AdminSettingsLayoutModel } from '../../../client/src/pm/AdminSettingsLayoutModel';
|
||||
import { NocoBaseBuildInPlugin } from '../nocobase-buildin-plugin';
|
||||
import { SettingsBuildInPlugin } from '../settings-app/SettingsBuildInPlugin';
|
||||
import { matchSettingsRoute, sortTopLevelSettings } from '../settings-center/utils';
|
||||
import { createMockSettingsClient } from './mockSettingsApplication';
|
||||
|
||||
class TestAclPlugin extends Plugin {
|
||||
async load() {
|
||||
@@ -22,7 +23,7 @@ class TestAclPlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
type MockClientApplication = ReturnType<typeof createMockClient>;
|
||||
type MockClientApplication = ReturnType<typeof createMockSettingsClient>;
|
||||
|
||||
const renderApp = async (app: MockClientApplication) => {
|
||||
const Root = app.getRootComponent();
|
||||
@@ -174,10 +175,10 @@ describe('settings center', () => {
|
||||
expect(sortTopLevelSettings(settings).map((item) => item.name)).toEqual(['api-keys', 'backups', 'system-settings']);
|
||||
});
|
||||
|
||||
it('should redirect /admin/settings to system-settings by default', async () => {
|
||||
const app = createMockClient({
|
||||
plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/admin/settings'] },
|
||||
it('should redirect /settings to system-settings by default', async () => {
|
||||
const app = createMockSettingsClient({
|
||||
plugins: [SettingsBuildInPlugin, TestAclPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/settings'] },
|
||||
});
|
||||
mockAdminRuntime(app);
|
||||
|
||||
@@ -188,9 +189,9 @@ describe('settings center', () => {
|
||||
});
|
||||
|
||||
it('should expose current language variable as enabled-language selector', async () => {
|
||||
const app = createMockClient({
|
||||
plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/admin/settings/system-settings'] },
|
||||
const app = createMockSettingsClient({
|
||||
plugins: [SettingsBuildInPlugin, TestAclPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/settings/system-settings'] },
|
||||
});
|
||||
mockAdminRuntime(app, {
|
||||
systemSettings: {
|
||||
@@ -218,9 +219,9 @@ describe('settings center', () => {
|
||||
});
|
||||
|
||||
it('should fallback to plugin-manager when system-settings is not allowed', async () => {
|
||||
const app = createMockClient({
|
||||
plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/admin/settings'] },
|
||||
const app = createMockSettingsClient({
|
||||
plugins: [SettingsBuildInPlugin, TestAclPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/settings'] },
|
||||
});
|
||||
mockAdminRuntime(app, {
|
||||
snippets: ['pm', '!pm.system-settings.system-settings'],
|
||||
@@ -244,9 +245,9 @@ describe('settings center', () => {
|
||||
});
|
||||
|
||||
it('should hide plugin-manager menu item when pm snippet is missing', async () => {
|
||||
const app = createMockClient({
|
||||
plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/admin/settings/system-settings'] },
|
||||
const app = createMockSettingsClient({
|
||||
plugins: [SettingsBuildInPlugin, TestAclPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/settings/system-settings'] },
|
||||
});
|
||||
mockAdminRuntime(app, {
|
||||
snippets: ['pm.system-settings.system-settings'],
|
||||
@@ -260,9 +261,9 @@ describe('settings center', () => {
|
||||
});
|
||||
|
||||
it('should show route empty state for unknown settings routes', async () => {
|
||||
const app = createMockClient({
|
||||
plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/admin/settings/unknown'] },
|
||||
const app = createMockSettingsClient({
|
||||
plugins: [SettingsBuildInPlugin, TestAclPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/settings/unknown'] },
|
||||
});
|
||||
mockAdminRuntime(app);
|
||||
|
||||
@@ -286,9 +287,9 @@ describe('settings center', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const app = createMockClient({
|
||||
plugins: [NocoBaseBuildInPlugin, TestAclPlugin, HiddenSettingsPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/admin/settings/hidden-demo'] },
|
||||
const app = createMockSettingsClient({
|
||||
plugins: [SettingsBuildInPlugin, TestAclPlugin, HiddenSettingsPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/settings/hidden-demo'] },
|
||||
});
|
||||
mockAdminRuntime(app);
|
||||
|
||||
@@ -313,9 +314,9 @@ describe('settings center', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const app = createMockClient({
|
||||
plugins: [NocoBaseBuildInPlugin, TestAclPlugin, ProtectedSettingsPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/admin/settings/secure-demo'] },
|
||||
const app = createMockSettingsClient({
|
||||
plugins: [SettingsBuildInPlugin, TestAclPlugin, ProtectedSettingsPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/settings/secure-demo'] },
|
||||
});
|
||||
mockAdminRuntime(app, {
|
||||
snippets: ['pm', 'pm.system-settings.system-settings', '!pm.secure-demo.index'],
|
||||
@@ -345,9 +346,9 @@ describe('settings center', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const app = createMockClient({
|
||||
plugins: [NocoBaseBuildInPlugin, TestAclPlugin, MenuAclPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/admin/settings/menu-acl-demo'] },
|
||||
const app = createMockSettingsClient({
|
||||
plugins: [SettingsBuildInPlugin, TestAclPlugin, MenuAclPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/settings/menu-acl-demo'] },
|
||||
});
|
||||
mockAdminRuntime(app, {
|
||||
snippets: ['pm', 'pm.system-settings.system-settings', '!pm.menu-acl-demo.menu'],
|
||||
@@ -378,9 +379,9 @@ describe('settings center', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const app = createMockClient({
|
||||
plugins: [NocoBaseBuildInPlugin, TestAclPlugin, ManySettingsPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/admin/settings/scroll-demo-29'] },
|
||||
const app = createMockSettingsClient({
|
||||
plugins: [SettingsBuildInPlugin, TestAclPlugin, ManySettingsPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/settings/scroll-demo-29'] },
|
||||
});
|
||||
mockAdminRuntime(app);
|
||||
|
||||
@@ -394,9 +395,9 @@ describe('settings center', () => {
|
||||
});
|
||||
|
||||
it('should save system settings through systemSettings:put', async () => {
|
||||
const app = createMockClient({
|
||||
plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/admin/settings/system-settings'] },
|
||||
const app = createMockSettingsClient({
|
||||
plugins: [SettingsBuildInPlugin, TestAclPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/settings/system-settings'] },
|
||||
});
|
||||
mockAdminRuntime(app);
|
||||
|
||||
@@ -413,9 +414,9 @@ describe('settings center', () => {
|
||||
});
|
||||
|
||||
it('should block invalid logo uploads by storage rules', async () => {
|
||||
const app = createMockClient({
|
||||
plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/admin/settings/system-settings'] },
|
||||
const app = createMockSettingsClient({
|
||||
plugins: [SettingsBuildInPlugin, TestAclPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/settings/system-settings'] },
|
||||
});
|
||||
const messageErrorSpy = vi.spyOn(message, 'error').mockImplementation(() => {
|
||||
return undefined as any;
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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 { render, screen, waitFor } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import React from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ACLRolesCheckProvider } from '../acl';
|
||||
import { Plugin } from '../Plugin';
|
||||
import { SettingsApplication } from '../settings-app/SettingsApplication';
|
||||
import { SettingsBuildInPlugin } from '../settings-app/SettingsBuildInPlugin';
|
||||
|
||||
class TestAclPlugin extends Plugin {
|
||||
async load() {
|
||||
this.app.use(ACLRolesCheckProvider);
|
||||
}
|
||||
}
|
||||
|
||||
class StandaloneSettingsPlugin extends Plugin {
|
||||
async load() {
|
||||
this.pluginSettingsManager.addMenuItem({ key: 'standalone', title: 'Standalone' });
|
||||
this.pluginSettingsManager.addPageTabItem({
|
||||
menuKey: 'standalone',
|
||||
key: 'index',
|
||||
title: 'Standalone',
|
||||
Component: () => <div>Standalone settings page</div>,
|
||||
sort: -1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class MultiPortalSettingsPlugin extends Plugin {
|
||||
async load() {
|
||||
this.pluginSettingsManager.addMenuItem({
|
||||
key: 'multi-portal',
|
||||
title: 'Portal manager',
|
||||
aclSnippet: 'pm.multi-portal',
|
||||
sort: -300,
|
||||
});
|
||||
this.pluginSettingsManager.addPageTabItem({
|
||||
menuKey: 'multi-portal',
|
||||
key: 'index',
|
||||
title: 'Portal manager',
|
||||
aclSnippet: 'pm.multi-portal',
|
||||
Component: () => <div>Portal manager page</div>,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class PrimarySettingsPlugin extends Plugin {
|
||||
async load() {
|
||||
this.pluginSettingsManager.addMenuItem({
|
||||
key: 'portal-manager',
|
||||
title: 'Portal manager',
|
||||
sort: -300,
|
||||
});
|
||||
this.pluginSettingsManager.addPageTabItem({
|
||||
menuKey: 'portal-manager',
|
||||
key: 'index',
|
||||
title: 'Portal manager',
|
||||
Component: () => <div>Portal manager page</div>,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('standalone settings layout root', () => {
|
||||
const originalLocation = window.location;
|
||||
const originalModernClientPrefix = window.__nocobase_modern_client_prefix__;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
|
||||
if (originalModernClientPrefix === undefined) {
|
||||
delete window.__nocobase_modern_client_prefix__;
|
||||
} else {
|
||||
window.__nocobase_modern_client_prefix__ = originalModernClientPrefix;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['/settings', '/settings/', '/settings/index'])(
|
||||
'redirects %s to multi-portal when it is accessible',
|
||||
async (initialEntry) => {
|
||||
const app = new SettingsApplication({
|
||||
plugins: [SettingsBuildInPlugin, MultiPortalSettingsPlugin],
|
||||
router: { type: 'memory', initialEntries: [initialEntry] },
|
||||
ws: false,
|
||||
});
|
||||
const apiMock = new MockAdapter(app.apiClient.axios);
|
||||
app.dataSourceManager.ensureLoaded = async () => {};
|
||||
apiMock.onGet('app:getLang').reply(200, {
|
||||
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
|
||||
});
|
||||
apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
|
||||
apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
|
||||
apiMock.onGet('systemSettings:get').reply(200, {
|
||||
data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
|
||||
});
|
||||
|
||||
const Root = app.getRootComponent();
|
||||
render(<Root />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(app.router.state.location.pathname).toBe('/settings/multi-portal');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('falls back to system-settings when multi-portal is not registered', async () => {
|
||||
const app = new SettingsApplication({
|
||||
plugins: [SettingsBuildInPlugin, StandaloneSettingsPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/settings'] },
|
||||
ws: false,
|
||||
});
|
||||
const apiMock = new MockAdapter(app.apiClient.axios);
|
||||
app.dataSourceManager.ensureLoaded = async () => {};
|
||||
apiMock.onGet('app:getLang').reply(200, {
|
||||
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
|
||||
});
|
||||
apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
|
||||
apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
|
||||
apiMock.onGet('systemSettings:get').reply(200, {
|
||||
data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
|
||||
});
|
||||
|
||||
const Root = app.getRootComponent();
|
||||
render(<Root />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(app.router.state.location.pathname).toBe('/settings/system-settings');
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to system-settings when multi-portal is not accessible', async () => {
|
||||
const app = new SettingsApplication({
|
||||
plugins: [SettingsBuildInPlugin, TestAclPlugin, MultiPortalSettingsPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/settings'] },
|
||||
ws: false,
|
||||
});
|
||||
const apiMock = new MockAdapter(app.apiClient.axios);
|
||||
app.dataSourceManager.ensureLoaded = async () => {};
|
||||
apiMock.onGet('app:getLang').reply(200, {
|
||||
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
|
||||
});
|
||||
apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
|
||||
apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
|
||||
apiMock.onGet('roles:check').reply(200, {
|
||||
data: { role: 'member', snippets: ['!pm.multi-portal'] },
|
||||
});
|
||||
apiMock.onGet('systemSettings:get').reply(200, {
|
||||
data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
|
||||
});
|
||||
|
||||
const Root = app.getRootComponent();
|
||||
render(<Root />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(app.router.state.location.pathname).toBe('/settings/system-settings');
|
||||
});
|
||||
});
|
||||
|
||||
it('groups negative-sort settings above plugin-manager', async () => {
|
||||
const app = new SettingsApplication({
|
||||
plugins: [SettingsBuildInPlugin, TestAclPlugin, PrimarySettingsPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/settings/portal-manager'] },
|
||||
ws: false,
|
||||
});
|
||||
const apiMock = new MockAdapter(app.apiClient.axios);
|
||||
app.dataSourceManager.ensureLoaded = async () => {};
|
||||
apiMock.onGet('app:getLang').reply(200, {
|
||||
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
|
||||
});
|
||||
apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
|
||||
apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
|
||||
apiMock.onGet('roles:check').reply(200, {
|
||||
data: { role: 'root', snippets: ['pm'] },
|
||||
});
|
||||
apiMock.onGet('systemSettings:get').reply(200, {
|
||||
data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
|
||||
});
|
||||
|
||||
const Root = app.getRootComponent();
|
||||
render(<Root />);
|
||||
|
||||
expect(await screen.findByText('Portal manager page')).toBeInTheDocument();
|
||||
|
||||
const portalManagerItem = screen.getByRole('menuitem', { name: 'Portal manager' });
|
||||
const pluginManagerItem = screen.getByRole('menuitem', { name: /Plugin manager$/ });
|
||||
const menu = portalManagerItem.closest('ul');
|
||||
const menuChildren = Array.from(menu?.children || []);
|
||||
const portalManagerIndex = menuChildren.indexOf(portalManagerItem);
|
||||
const pluginManagerIndex = menuChildren.indexOf(pluginManagerItem);
|
||||
const firstDividerIndex = menuChildren.findIndex((item) => item.classList.contains('ant-menu-item-divider'));
|
||||
|
||||
expect(portalManagerIndex).toBeLessThan(pluginManagerIndex);
|
||||
expect(pluginManagerIndex).toBeLessThan(firstDividerIndex);
|
||||
});
|
||||
|
||||
it('uses document navigation to the standalone Settings signin page when unauthenticated', async () => {
|
||||
const replace = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { ...originalLocation, replace },
|
||||
});
|
||||
window.__nocobase_modern_client_prefix__ = 'v';
|
||||
const app = new SettingsApplication({
|
||||
plugins: [SettingsBuildInPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/settings/workflow?tab=list#recent'] },
|
||||
ws: false,
|
||||
});
|
||||
const apiMock = new MockAdapter(app.apiClient.axios);
|
||||
apiMock.onGet('app:getLang').reply(200, {
|
||||
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
|
||||
});
|
||||
apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
|
||||
apiMock.onGet('systemSettings:get').reply(200, {
|
||||
data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
|
||||
});
|
||||
apiMock.onGet('/auth:check').reply(200, { data: {} });
|
||||
|
||||
const Root = app.getRootComponent();
|
||||
render(<Root />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(replace).toHaveBeenCalledWith('/settings/signin?redirect=%2Fsettings%2Fworkflow%3Ftab%3Dlist%23recent');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render the Settings header before the initial auth check completes', async () => {
|
||||
let resolveAuthCheck: (response: [number, { data: { id: number } }]) => void = () => {
|
||||
throw new Error('Auth check resolver is not initialized');
|
||||
};
|
||||
const authCheckResponse = new Promise<[number, { data: { id: number } }]>((resolve) => {
|
||||
resolveAuthCheck = resolve;
|
||||
});
|
||||
const app = new SettingsApplication({
|
||||
plugins: [SettingsBuildInPlugin],
|
||||
router: { type: 'memory', initialEntries: ['/settings'] },
|
||||
ws: false,
|
||||
});
|
||||
const apiMock = new MockAdapter(app.apiClient.axios);
|
||||
app.dataSourceManager.ensureLoaded = async () => {};
|
||||
apiMock.onGet('app:getLang').reply(200, {
|
||||
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
|
||||
});
|
||||
apiMock.onGet('/auth:check').reply(() => authCheckResponse);
|
||||
apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
|
||||
apiMock.onGet('systemSettings:get').reply(200, {
|
||||
data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
|
||||
});
|
||||
|
||||
const Root = app.getRootComponent();
|
||||
render(<Root />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMock.history.get.some((request) => request.url === '/auth:check')).toBe(true);
|
||||
});
|
||||
expect(screen.queryByRole('banner')).not.toBeInTheDocument();
|
||||
|
||||
resolveAuthCheck([200, { data: { id: 1 } }]);
|
||||
expect(await screen.findByRole('banner')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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 { afterEach, describe, expect, it } from 'vitest';
|
||||
import { resolveStandaloneSettingsPath } from '../settings-app/runtimePaths';
|
||||
|
||||
const originalModernPrefix = window.__nocobase_modern_client_prefix__;
|
||||
|
||||
const createApp = (publicPath: string, basename: string, name = 'main') =>
|
||||
({
|
||||
name,
|
||||
getPublicPath: () => publicPath,
|
||||
router: {
|
||||
getBasename: () => basename,
|
||||
},
|
||||
}) as any;
|
||||
|
||||
describe('standalone settings runtime paths', () => {
|
||||
afterEach(() => {
|
||||
window.__nocobase_modern_client_prefix__ = originalModernPrefix;
|
||||
});
|
||||
|
||||
it('maps legacy v2 settings paths to the root-public standalone SPA', () => {
|
||||
const app = createApp('/nocobase/v/', '/nocobase/v');
|
||||
|
||||
expect(resolveStandaloneSettingsPath(app, '/nocobase/v/admin/settings/system-settings?tab=mail#smtp')).toBe(
|
||||
'/nocobase/settings/system-settings?tab=mail#smtp',
|
||||
);
|
||||
expect(resolveStandaloneSettingsPath(app, '/nocobase/v/admin/workflow/workflows/42?tab=nodes#canvas')).toBe(
|
||||
'/nocobase/settings/workflow/workflows/42?tab=nodes#canvas',
|
||||
);
|
||||
expect(resolveStandaloneSettingsPath(app, '/nocobase/v/admin/workflow/executions/88')).toBe(
|
||||
'/nocobase/settings/workflow/executions/88',
|
||||
);
|
||||
expect(resolveStandaloneSettingsPath(app, '/nocobase/v/admin/ai/knowledge-base/detail/k1/documents')).toBe(
|
||||
'/nocobase/settings/ai/knowledge-base/detail/k1/documents',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves both supported sub-application scopes', () => {
|
||||
const appsApp = createApp('/nocobase/v/', '/nocobase/v/apps/demo');
|
||||
const newApp = createApp('/nocobase/v/', '/nocobase/v/_app/demo');
|
||||
|
||||
expect(resolveStandaloneSettingsPath(appsApp, '/nocobase/v/apps/demo/admin/settings/routes')).toBe(
|
||||
'/nocobase/settings/apps/demo/routes',
|
||||
);
|
||||
expect(resolveStandaloneSettingsPath(newApp, '/nocobase/v/_app/demo/admin/settings/routes')).toBe(
|
||||
'/nocobase/settings/_app/demo/routes',
|
||||
);
|
||||
});
|
||||
|
||||
it('supports a custom modern prefix without leaking it into settings URLs', () => {
|
||||
window.__nocobase_modern_client_prefix__ = 'modern';
|
||||
const app = createApp('/base/modern/', '/base/modern');
|
||||
|
||||
expect(resolveStandaloneSettingsPath(app, '/base/modern/admin/settings/security')).toBe('/base/settings/security');
|
||||
});
|
||||
|
||||
it('does not treat an apps segment inside the main application public path as a sub-application scope', () => {
|
||||
window.__nocobase_modern_client_prefix__ = 'modern';
|
||||
const app = createApp('/tenant/apps/root/modern/', '/tenant/apps/root/modern');
|
||||
|
||||
expect(resolveStandaloneSettingsPath(app, '/tenant/apps/root/modern/admin/settings/security')).toBe(
|
||||
'/tenant/apps/root/settings/security',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['apps', '_app'])(
|
||||
'does not remove a matching %s segment from the root public path for a real sub-application',
|
||||
(scope) => {
|
||||
window.__nocobase_modern_client_prefix__ = 'modern';
|
||||
const app = createApp(`/tenant/${scope}/demo/modern/`, `/tenant/${scope}/demo/modern/${scope}/demo`, 'demo');
|
||||
|
||||
expect(
|
||||
resolveStandaloneSettingsPath(app, `/tenant/${scope}/demo/modern/${scope}/demo/admin/settings/security`),
|
||||
).toBe(`/tenant/${scope}/demo/settings/${scope}/demo/security`);
|
||||
expect(
|
||||
resolveStandaloneSettingsPath(
|
||||
app,
|
||||
`/tenant/${scope}/demo/modern/${scope}/demo/admin/settings/mail/oauth2?code=abc#done`,
|
||||
),
|
||||
).toBe(`/tenant/${scope}/demo/${scope}/demo/admin/settings/mail/oauth2?code=abc#done`);
|
||||
},
|
||||
);
|
||||
|
||||
it('returns the legacy V1 callback for Email OAuth', () => {
|
||||
const app = createApp('/nocobase/v/', '/nocobase/v/apps/demo');
|
||||
|
||||
expect(resolveStandaloneSettingsPath(app, '/nocobase/v/apps/demo/admin/settings/mail/oauth2?code=abc#done')).toBe(
|
||||
'/nocobase/apps/demo/admin/settings/mail/oauth2?code=abc#done',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 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 { render, screen } from '@testing-library/react';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setCurrentUserAuthStatus } from '../nocobase-buildin-plugin/currentUserAuthStatus';
|
||||
import { SettingsShell } from '../settings-app/SettingsShell';
|
||||
import type { ThemeConfig } from '../theme';
|
||||
|
||||
const userCenterModel = { uid: 'settings-user-center' };
|
||||
const createModel = vi.fn(() => userCenterModel);
|
||||
const matchRoutes = vi.fn(() => [{ route: { id: 'settings' } }]);
|
||||
const mockApp = {
|
||||
flowEngine: {
|
||||
createModel,
|
||||
getModel: vi.fn(() => null),
|
||||
getModelClass: vi.fn(() => true),
|
||||
},
|
||||
router: {
|
||||
matchRoutes,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('../hooks/useApp', () => ({
|
||||
useApp: () => mockApp,
|
||||
}));
|
||||
|
||||
vi.mock('../flow/admin-shell/admin-layout/NocoBaseLogo', () => ({
|
||||
NocoBaseLogo: () => <div data-testid="settings-logo">logo</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../flow/admin-shell/admin-layout/HelpLite', () => ({
|
||||
HelpLite: () => <div data-testid="settings-help">help</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@nocobase/flow-engine', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nocobase/flow-engine')>();
|
||||
return {
|
||||
...actual,
|
||||
FlowModelRenderer: ({ model }: { model: { uid: string } }) => (
|
||||
<div data-testid="settings-user-center">{model.uid}</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
describe('SettingsShell', () => {
|
||||
beforeEach(() => {
|
||||
createModel.mockClear();
|
||||
matchRoutes.mockReset();
|
||||
matchRoutes.mockReturnValue([{ route: { id: 'settings' } }]);
|
||||
setCurrentUserAuthStatus(mockApp, 'authenticated');
|
||||
});
|
||||
|
||||
it('renders only the settings logo, help and user center around its content', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/settings/system-settings']}>
|
||||
<SettingsShell>
|
||||
<div>settings content</div>
|
||||
</SettingsShell>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('settings-logo')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('settings-help')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('settings-user-center')).toHaveTextContent('settings-user-center');
|
||||
expect(screen.getByText('settings content')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('plugin-settings-button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('notifications-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the designated header color when the shared header token uses its dark fallback', () => {
|
||||
render(
|
||||
<ConfigProvider
|
||||
theme={
|
||||
{
|
||||
token: {
|
||||
colorBgHeader: '#001529',
|
||||
colorPrimary: '#1777FF',
|
||||
},
|
||||
} as ThemeConfig
|
||||
}
|
||||
>
|
||||
<MemoryRouter initialEntries={['/settings/system-settings']}>
|
||||
<SettingsShell>
|
||||
<div>settings content</div>
|
||||
</SettingsShell>
|
||||
</MemoryRouter>
|
||||
</ConfigProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('banner')).toHaveStyle({ background: '#176CE1' });
|
||||
});
|
||||
|
||||
it('places the settings content and embed container side by side below the header', () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter initialEntries={['/settings/theme-editor']}>
|
||||
<SettingsShell>
|
||||
<div>settings content</div>
|
||||
</SettingsShell>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const header = screen.getByRole('banner');
|
||||
const content = screen.getByRole('main');
|
||||
const embedContainer = container.querySelector<HTMLElement>('#nocobase-embed-container');
|
||||
const workspace = content.parentElement;
|
||||
|
||||
expect(embedContainer).not.toBeNull();
|
||||
if (!embedContainer) {
|
||||
throw new Error('Expected the Settings shell to render the global embed container');
|
||||
}
|
||||
expect(header.nextElementSibling).toBe(workspace);
|
||||
expect(workspace).toHaveStyle({
|
||||
display: 'flex',
|
||||
flex: '1',
|
||||
minWidth: '0',
|
||||
minHeight: '0',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
expect(workspace?.children).toHaveLength(2);
|
||||
expect(workspace?.firstElementChild).toBe(content);
|
||||
expect(workspace?.lastElementChild).toBe(embedContainer);
|
||||
expect(content).toHaveStyle({
|
||||
flex: '1',
|
||||
minWidth: '0',
|
||||
minHeight: '0',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
expect(embedContainer).toHaveStyle({
|
||||
flexShrink: '0',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
embedContainer.style.width = '33.3%';
|
||||
embedContainer.style.maxWidth = '800px';
|
||||
expect(workspace?.lastElementChild).toBe(embedContainer);
|
||||
expect(embedContainer).toHaveStyle({ width: '33.3%', maxWidth: '800px' });
|
||||
|
||||
embedContainer.style.width = 'auto';
|
||||
embedContainer.style.maxWidth = 'none';
|
||||
expect(content).toHaveStyle({ flex: '1' });
|
||||
expect(embedContainer).toHaveStyle({ width: 'auto', maxWidth: 'none' });
|
||||
});
|
||||
|
||||
it.each(['auth.signin', '2fa.verify'])('does not render the settings shell for %s', (routeId) => {
|
||||
matchRoutes.mockReturnValue([{ route: { id: routeId } }]);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[routeId === 'auth.signin' ? '/settings/signin' : '/settings/2fa']}>
|
||||
<SettingsShell>
|
||||
<div>authentication content</div>
|
||||
</SettingsShell>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('authentication content')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('banner')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('settings-logo')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('settings-help')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('settings-user-center')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('#nocobase-embed-container')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,19 @@
|
||||
*/
|
||||
|
||||
import type { BaseApplication } from './BaseApplication';
|
||||
import {
|
||||
resolveSettingsAppScope,
|
||||
resolveSettingsAppScopeWithinPublicPath,
|
||||
resolveSettingsDocumentPath,
|
||||
type SettingsAppScope,
|
||||
} from './settings-app/settingsDocumentPath';
|
||||
|
||||
type AppLike = Pick<BaseApplication<any>, 'getPublicPath'> & {
|
||||
name?: string;
|
||||
pluginSettingsManager?: {
|
||||
getRouteName?: (name: string) => string;
|
||||
getRoutePath?: (name: string) => string;
|
||||
};
|
||||
router?: {
|
||||
getBasename?: () => string | undefined;
|
||||
};
|
||||
@@ -52,7 +63,8 @@ function normalizePublicPath(value?: string) {
|
||||
}
|
||||
|
||||
function normalizePathname(value?: string) {
|
||||
const normalized = ensureLeadingSlash(String(value || '/').trim() || '/').replace(/\/{2,}/g, '/');
|
||||
const pathname = ensureLeadingSlash(String(value || '/').trim() || '/');
|
||||
const normalized = new URL(pathname, 'http://nocobase.local').pathname.replace(/\/{2,}/g, '/');
|
||||
if (normalized !== '/' && normalized.endsWith('/')) {
|
||||
return trimTrailingSlashes(normalized);
|
||||
}
|
||||
@@ -179,7 +191,53 @@ function joinRootRelativePath(basePath: string, pathname: string) {
|
||||
return normalizePathname(`/${trimLeadingSlashes(normalizedBasePath)}/${trimLeadingSlashes(normalizedPathname)}`);
|
||||
}
|
||||
|
||||
function isStandaloneSettingsApp(app: AppLike) {
|
||||
return app.pluginSettingsManager?.getRouteName?.('') === 'settings.';
|
||||
}
|
||||
|
||||
function getSettingsRootPublicPath(app: AppLike) {
|
||||
const publicPath = normalizePublicPath(app.getPublicPath());
|
||||
if (isStandaloneSettingsApp(app)) {
|
||||
return publicPath;
|
||||
}
|
||||
|
||||
const segments = trimTrailingSlashes(publicPath).split('/');
|
||||
segments.pop();
|
||||
return normalizePublicPath(segments.join('/') || '/');
|
||||
}
|
||||
|
||||
function getSettingsAppScope(app: AppLike): SettingsAppScope {
|
||||
const publicPath = app.getPublicPath();
|
||||
return (
|
||||
resolveSettingsAppScopeWithinPublicPath(publicPath, app.router?.getBasename?.()) ||
|
||||
(app.name && app.name !== 'main' ? resolveSettingsAppScope(`/apps/${app.name}`) : '')
|
||||
);
|
||||
}
|
||||
|
||||
function getStandaloneSettingsBasePath(app: AppLike) {
|
||||
return resolveSettingsDocumentPath(getSettingsRootPublicPath(app), getSettingsAppScope(app), '/settings');
|
||||
}
|
||||
|
||||
function isStandaloneSettingsTarget(app: AppLike, pathname: string) {
|
||||
const normalizedPathname = normalizePathname(pathname);
|
||||
const settingsBasePath = normalizePathname(getStandaloneSettingsBasePath(app));
|
||||
return normalizedPathname === settingsBasePath || normalizedPathname.startsWith(`${settingsBasePath}/`);
|
||||
}
|
||||
|
||||
function isSettingsTargetForAnotherApp(app: AppLike, pathname: string) {
|
||||
const normalizedPathname = normalizePathname(pathname);
|
||||
const rootPublicPath = trimTrailingSlashes(getSettingsRootPublicPath(app)) || '';
|
||||
const pathWithinRoot =
|
||||
rootPublicPath && normalizedPathname.startsWith(`${rootPublicPath}/`)
|
||||
? normalizedPathname.slice(rootPublicPath.length)
|
||||
: normalizedPathname;
|
||||
return /^\/settings(?:\/|$)/.test(pathWithinRoot);
|
||||
}
|
||||
|
||||
function getV2SigninPath(app: AppLike) {
|
||||
if (isStandaloneSettingsApp(app)) {
|
||||
return joinRootRelativePath(getStandaloneSettingsBasePath(app), '/signin');
|
||||
}
|
||||
return joinRootRelativePath(getV2EffectiveBasePath(app), '/signin');
|
||||
}
|
||||
|
||||
@@ -204,7 +262,7 @@ function getDefaultV2AdminRedirectPath(app: AppLike) {
|
||||
return joinRootRelativePath(getV2EffectiveBasePath(app), '/admin');
|
||||
}
|
||||
|
||||
function isSafeRootRelativePath(value?: string | null) {
|
||||
function isSafeRootRelativePath(value?: string | null): value is string {
|
||||
return !!value && value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\');
|
||||
}
|
||||
|
||||
@@ -224,6 +282,10 @@ export function normalizeV2RedirectPath(app: AppLike, target?: string | null, fa
|
||||
let { pathname, search, hash } = splitPathLike(rawTarget);
|
||||
let normalizedPathname = normalizePathname(pathname);
|
||||
|
||||
if (isStandaloneSettingsTarget(app, normalizedPathname)) {
|
||||
return `${preserveTrailingSlash(pathname, normalizedPathname)}${normalizeSearch(search)}${normalizeHash(hash)}`;
|
||||
}
|
||||
|
||||
// Already under the current v2 runtime, e.g. `/v/apps/a/admin/`.
|
||||
if (basePath === '/' || normalizedPathname === basePath || normalizedPathname.startsWith(`${basePath}/`)) {
|
||||
return `${preserveTrailingSlash(pathname, normalizedPathname)}${normalizeSearch(search)}${normalizeHash(hash)}`;
|
||||
@@ -237,6 +299,11 @@ export function normalizeV2RedirectPath(app: AppLike, target?: string | null, fa
|
||||
normalizedPathname = normalizePathname(pathname);
|
||||
}
|
||||
|
||||
if (isSettingsTargetForAnotherApp(app, normalizedPathname)) {
|
||||
({ pathname, search, hash } = splitPathLike(fallbackTarget));
|
||||
normalizedPathname = normalizePathname(pathname);
|
||||
}
|
||||
|
||||
// Basename-relative target, e.g. `/admin/`, becomes `/v/apps/a/admin/`.
|
||||
const joinedPathname = preserveTrailingSlash(pathname, joinRootRelativePath(basePath, normalizedPathname));
|
||||
return `${joinedPathname}${normalizeSearch(search)}${normalizeHash(hash)}`;
|
||||
@@ -306,7 +373,9 @@ export function resolveV2SigninRedirect(value: string | undefined | null, app: A
|
||||
return null;
|
||||
}
|
||||
|
||||
const validPathnames = new Set(['/signin', getV2SigninPath(app)]);
|
||||
const validPathnames = new Set(
|
||||
isStandaloneSettingsApp(app) ? [getV2SigninPath(app)] : ['/signin', getV2SigninPath(app)],
|
||||
);
|
||||
if (!validPathnames.has(url.pathname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { theme as antdTheme, ConfigProvider, Grid, Popover } from 'antd';
|
||||
import { createStyles, createGlobalStyle } from 'antd-style';
|
||||
import React, { FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
AdminLayoutMenuModelRenderer,
|
||||
@@ -35,7 +34,6 @@ import { ResetThemeTokenAndKeepAlgorithm } from './ResetThemeTokenAndKeepAlgorit
|
||||
import { PinnedPluginListLite } from './PinnedPluginListLite';
|
||||
import { useApplications } from './useApplications';
|
||||
import { useGlobalTheme, type CustomToken } from '../../../theme';
|
||||
import { useSystemSettings } from '../../system-settings';
|
||||
import { NocoBaseDesktopRouteType, type NocoBaseDesktopRoute } from '../../../flow-compat';
|
||||
import {
|
||||
FLOW_SETTINGS_PREFERENCE_CHANGE_EVENT,
|
||||
@@ -44,60 +42,7 @@ import {
|
||||
} from './flowSettingsPreference';
|
||||
import { joinAdminLayoutRoutePath, type AdminLayoutRoutePathLike } from './resolveAdminRouteRuntimeTarget';
|
||||
import { useAppListRender } from './AppListRender';
|
||||
|
||||
const className1 = css`
|
||||
height: var(--nb-header-height);
|
||||
margin-right: 4px;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
`;
|
||||
const className1WithFixedWidth = css`
|
||||
${className1}
|
||||
width: 168px;
|
||||
`;
|
||||
const className1WithAutoWidth = css`
|
||||
${className1}
|
||||
width: auto;
|
||||
min-width: 168px;
|
||||
`;
|
||||
const className2 = css`
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
const className3 = css`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const NocoBaseLogo = observer(() => {
|
||||
const { token } = antdTheme.useToken();
|
||||
const customToken = token as CustomToken;
|
||||
const result = useSystemSettings();
|
||||
const { t } = useTranslation('lm-collections');
|
||||
const fontSizeStyle = useMemo(() => ({ fontSize: customToken.fontSizeHeading3 }), [customToken.fontSizeHeading3]);
|
||||
|
||||
const hasLogo = result?.data?.data?.logo?.url;
|
||||
const logo = hasLogo ? (
|
||||
<img className={className2} src={result?.data?.data?.logo?.url} />
|
||||
) : (
|
||||
<span style={fontSizeStyle} className={className3}>
|
||||
{t(result?.data?.data?.title)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={hasLogo ? className1WithFixedWidth : className1WithAutoWidth}>{result?.loading ? null : logo}</div>
|
||||
);
|
||||
});
|
||||
import { NocoBaseLogo } from './NocoBaseLogo';
|
||||
|
||||
const resetStyle = css`
|
||||
.ant-layout-sider-children {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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 { css } from '@emotion/css';
|
||||
import { observer } from '@nocobase/flow-engine';
|
||||
import { theme as antdTheme } from 'antd';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSystemSettings } from '../../system-settings';
|
||||
import type { CustomToken } from '../../../theme';
|
||||
|
||||
const logoContainerClassName = css`
|
||||
height: var(--nb-header-height);
|
||||
margin-right: 4px;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const fixedWidthClassName = css`
|
||||
${logoContainerClassName}
|
||||
width: 168px;
|
||||
`;
|
||||
|
||||
const autoWidthClassName = css`
|
||||
${logoContainerClassName}
|
||||
width: auto;
|
||||
min-width: 168px;
|
||||
`;
|
||||
|
||||
const logoImageClassName = css`
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const titleClassName = css`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const NocoBaseLogo = observer(() => {
|
||||
const { token } = antdTheme.useToken();
|
||||
const customToken = token as CustomToken;
|
||||
const result = useSystemSettings();
|
||||
const { t } = useTranslation('lm-collections');
|
||||
const title = t(result?.data?.data?.title || 'NocoBase');
|
||||
const logoUrl = result?.data?.data?.logo?.url;
|
||||
const titleStyle = useMemo<React.CSSProperties>(
|
||||
() => ({ color: customToken.colorTextHeaderMenu, fontSize: customToken.fontSizeHeading3 }),
|
||||
[customToken.colorTextHeaderMenu, customToken.fontSizeHeading3],
|
||||
);
|
||||
|
||||
const logo = logoUrl ? (
|
||||
<img alt={title} className={logoImageClassName} src={logoUrl} />
|
||||
) : (
|
||||
<span style={titleStyle} className={titleClassName}>
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
|
||||
return <div className={logoUrl ? fixedWidthClassName : autoWidthClassName}>{result?.loading ? null : logo}</div>;
|
||||
});
|
||||
|
||||
NocoBaseLogo.displayName = 'NocoBaseLogo';
|
||||
@@ -24,7 +24,9 @@ const topbarActionsBarClassName = css`
|
||||
align-items: center;
|
||||
color: var(--nb-topbar-action-color);
|
||||
|
||||
.nb-topbar-actions-list {
|
||||
.nb-topbar-actions-list,
|
||||
.nb-topbar-utility-actions-list,
|
||||
.nb-topbar-plugin-settings-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
@@ -127,12 +129,18 @@ const getTopbarActionId = (action: TopbarActionModel) => {
|
||||
return action?.actionId || action?.uid || '';
|
||||
};
|
||||
|
||||
const PLUGIN_SETTINGS_ACTION_ID = 'plugin-settings';
|
||||
|
||||
const TopbarActionsContent = React.memo((props: { actions: TopbarActionModel[]; onActionClick?: () => void }) => {
|
||||
const { allow } = useAclSnippets();
|
||||
const { token } = theme.useToken();
|
||||
const customToken = token as CustomToken;
|
||||
const actions = useMemo(() => getVisibleTopbarActions(props.actions, allow), [allow, props.actions]);
|
||||
const mainActions = actions.filter((action) => getTopbarActionId(action) !== USER_CENTER_ACTION_ID);
|
||||
const mainActions = actions.filter((action) => {
|
||||
const actionId = getTopbarActionId(action);
|
||||
return actionId !== PLUGIN_SETTINGS_ACTION_ID && actionId !== USER_CENTER_ACTION_ID;
|
||||
});
|
||||
const pluginSettingsAction = actions.find((action) => getTopbarActionId(action) === PLUGIN_SETTINGS_ACTION_ID);
|
||||
const userCenterAction = actions.find((action) => getTopbarActionId(action) === USER_CENTER_ACTION_ID);
|
||||
|
||||
return (
|
||||
@@ -153,18 +161,33 @@ const TopbarActionsContent = React.memo((props: { actions: TopbarActionModel[];
|
||||
<ConfigProvider theme={dividerTheme}>
|
||||
<Divider type="vertical" />
|
||||
</ConfigProvider>
|
||||
<HelpLite />
|
||||
{userCenterAction ? (
|
||||
<ErrorBoundary
|
||||
key={userCenterAction.uid}
|
||||
FallbackComponent={TopbarActionErrorFallback}
|
||||
onError={(error) => {
|
||||
console.error('[NocoBase] Topbar action render failed.', error);
|
||||
}}
|
||||
>
|
||||
<FlowModelRenderer model={userCenterAction} />
|
||||
</ErrorBoundary>
|
||||
) : null}
|
||||
<div className="nb-topbar-utility-actions-list">
|
||||
{pluginSettingsAction ? (
|
||||
<span className="nb-topbar-plugin-settings-action" onClick={props.onActionClick}>
|
||||
<ErrorBoundary
|
||||
key={pluginSettingsAction.uid}
|
||||
FallbackComponent={TopbarActionErrorFallback}
|
||||
onError={(error) => {
|
||||
console.error('[NocoBase] Topbar action render failed.', error);
|
||||
}}
|
||||
>
|
||||
<FlowModelRenderer model={pluginSettingsAction} />
|
||||
</ErrorBoundary>
|
||||
</span>
|
||||
) : null}
|
||||
<HelpLite />
|
||||
{userCenterAction ? (
|
||||
<ErrorBoundary
|
||||
key={userCenterAction.uid}
|
||||
FallbackComponent={TopbarActionErrorFallback}
|
||||
onError={(error) => {
|
||||
console.error('[NocoBase] Topbar action render failed.', error);
|
||||
}}
|
||||
>
|
||||
<FlowModelRenderer model={userCenterAction} />
|
||||
</ErrorBoundary>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
+77
-13
@@ -136,6 +136,16 @@ describe('TopbarActionsBar helpers', () => {
|
||||
title: 'Plugin manager',
|
||||
path: '/admin/settings/plugin-manager',
|
||||
icon: null,
|
||||
sort: -200,
|
||||
componentLoader: async () => null,
|
||||
},
|
||||
{
|
||||
key: 'multi-portal',
|
||||
name: 'multi-portal',
|
||||
title: 'Portal manager',
|
||||
path: '/admin/settings/multi-portal',
|
||||
icon: null,
|
||||
sort: -300,
|
||||
componentLoader: async () => null,
|
||||
},
|
||||
{
|
||||
@@ -159,13 +169,14 @@ describe('TopbarActionsBar helpers', () => {
|
||||
});
|
||||
|
||||
expect((items as any[]).map((item) => item.type || item.key)).toEqual([
|
||||
'multi-portal',
|
||||
'plugin-manager',
|
||||
'divider',
|
||||
'system-settings',
|
||||
'divider',
|
||||
'security',
|
||||
]);
|
||||
expect((items as any[])[2]).toMatchObject({
|
||||
expect((items as any[]).find((item) => item.key === 'system-settings')).toMatchObject({
|
||||
key: 'system-settings',
|
||||
name: 'system-settings',
|
||||
path: '/admin/settings/system-settings',
|
||||
@@ -191,7 +202,7 @@ describe('TopbarActionsBar helpers', () => {
|
||||
renderSettingsLabel((items as any[])[0].label, '/sales/p1');
|
||||
|
||||
const link = screen.getByRole('link', { name: 'System settings' });
|
||||
expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
|
||||
expect(link).toHaveAttribute('href', '/nocobase/settings/system-settings');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
|
||||
expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
|
||||
@@ -216,7 +227,7 @@ describe('TopbarActionsBar helpers', () => {
|
||||
renderSettingsLabel((items as any[])[0].label, '/sales/p1');
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Plugin manager' });
|
||||
expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/plugin-manager');
|
||||
expect(link).toHaveAttribute('href', '/nocobase/settings/plugin-manager');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
|
||||
expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
|
||||
@@ -241,13 +252,13 @@ describe('TopbarActionsBar helpers', () => {
|
||||
renderSettingsLabel((items as any[])[0].label, '/apps/a_9xlild35jir/crm-amd/ekeisumx1zu');
|
||||
|
||||
const link = screen.getByRole('link', { name: 'System settings' });
|
||||
expect(link).toHaveAttribute('href', '/nocobase/v/apps/a_9xlild35jir/admin/settings/system-settings');
|
||||
expect(link).toHaveAttribute('href', '/nocobase/settings/apps/a_9xlild35jir/system-settings');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
|
||||
expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
|
||||
});
|
||||
|
||||
it('should keep regular admin settings as SPA links inside admin runtime', () => {
|
||||
it('should open regular admin settings in the standalone SPA from admin runtime', () => {
|
||||
const items = getTopbarPluginSettingsItems({
|
||||
canManagePlugins: false,
|
||||
t: (key) => key,
|
||||
@@ -266,11 +277,12 @@ describe('TopbarActionsBar helpers', () => {
|
||||
renderSettingsLabel((items as any[])[0].label, '/admin/settings/routes');
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Routes' });
|
||||
expect(link).toHaveAttribute('href', '/admin/settings/routes');
|
||||
expect(link).not.toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('href', '/nocobase/settings/routes');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
|
||||
});
|
||||
|
||||
it('should keep sub-app admin settings in the current window inside sub-app admin runtime', () => {
|
||||
it('should open sub-app settings in the standalone SPA from sub-app admin runtime', () => {
|
||||
const items = getTopbarPluginSettingsItems({
|
||||
canManagePlugins: false,
|
||||
t: (key) => key,
|
||||
@@ -289,8 +301,9 @@ describe('TopbarActionsBar helpers', () => {
|
||||
renderSettingsLabel((items as any[])[0].label, '/apps/a_9xlild35jir/admin/settings/routes');
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Routes' });
|
||||
expect(link).toHaveAttribute('href', '/nocobase/v/apps/a_9xlild35jir/admin/settings/routes');
|
||||
expect(link).not.toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('href', '/nocobase/settings/apps/a_9xlild35jir/routes');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
|
||||
});
|
||||
|
||||
it('should not treat admin-like paths as admin runtime', () => {
|
||||
@@ -312,11 +325,11 @@ describe('TopbarActionsBar helpers', () => {
|
||||
renderSettingsLabel((items as any[])[0].label, '/admin2/foo');
|
||||
|
||||
const link = screen.getByRole('link', { name: 'System settings' });
|
||||
expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
|
||||
expect(link).toHaveAttribute('href', '/nocobase/settings/system-settings');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
it('should not duplicate the basename when building new-tab settings hrefs', () => {
|
||||
it('should remove the modern basename when building standalone settings hrefs', () => {
|
||||
const items = getTopbarPluginSettingsItems({
|
||||
canManagePlugins: false,
|
||||
t: (key) => key,
|
||||
@@ -335,7 +348,30 @@ describe('TopbarActionsBar helpers', () => {
|
||||
renderSettingsLabel((items as any[])[0].label, '/sales/p1');
|
||||
|
||||
const link = screen.getByRole('link', { name: 'System settings' });
|
||||
expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
|
||||
expect(link).toHaveAttribute('href', '/nocobase/settings/system-settings');
|
||||
});
|
||||
|
||||
it('should preserve the new sub-app scope in standalone settings hrefs', () => {
|
||||
const items = getTopbarPluginSettingsItems({
|
||||
canManagePlugins: false,
|
||||
t: (key) => key,
|
||||
settings: [
|
||||
{
|
||||
key: 'system-settings',
|
||||
name: 'system-settings',
|
||||
title: 'System settings',
|
||||
path: '/admin/settings/system-settings',
|
||||
icon: null,
|
||||
componentLoader: async () => null,
|
||||
},
|
||||
] as any,
|
||||
});
|
||||
|
||||
renderSettingsLabel((items as any[])[0].label, '/_app/a_new/admin/settings/system-settings');
|
||||
|
||||
const link = screen.getByRole('link', { name: 'System settings' });
|
||||
expect(link).toHaveAttribute('href', '/nocobase/settings/_app/a_new/system-settings');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
it('should keep external settings opening in a new tab', () => {
|
||||
@@ -402,6 +438,34 @@ describe('TopbarActionsBar', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should group plugin settings with Help and user center', () => {
|
||||
render(
|
||||
<TopbarActionsBar
|
||||
actions={[
|
||||
createAction({ uid: 'notification', actionId: 'notification' }),
|
||||
createAction({ uid: 'plugin-settings', actionId: 'plugin-settings' }),
|
||||
createAction({ uid: 'user-center', actionId: 'user-center' }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const notification = screen.getByTestId('flow-model-notification');
|
||||
const pluginSettings = screen.getByTestId('flow-model-plugin-settings');
|
||||
const help = screen.getByTestId('help-lite');
|
||||
const userCenter = screen.getByTestId('flow-model-user-center');
|
||||
const mainGroup = notification.closest('.nb-topbar-actions-list');
|
||||
const utilityGroup = pluginSettings.closest('.nb-topbar-utility-actions-list');
|
||||
|
||||
expect(mainGroup).toContainElement(notification);
|
||||
expect(mainGroup).not.toContainElement(pluginSettings);
|
||||
expect(utilityGroup).not.toBeNull();
|
||||
expect(utilityGroup).toContainElement(pluginSettings);
|
||||
expect(utilityGroup).toContainElement(help);
|
||||
expect(utilityGroup).toContainElement(userCenter);
|
||||
expect(pluginSettings.compareDocumentPosition(help) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(help.compareDocumentPosition(userCenter) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should keep HelpLite rendered when one action fails', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
|
||||
@@ -14,12 +14,11 @@ import { Button, Dropdown, theme, Tooltip, type ButtonProps, type MenuProps } fr
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useACLRoleContext } from '../../../acl';
|
||||
import type { BaseApplication } from '../../../BaseApplication';
|
||||
import type { PluginSettingsPageType } from '../../../PluginSettingsManager';
|
||||
import { shouldOpenAdminRouteInNewWindow } from '../../../RouterManager';
|
||||
import { useApp } from '../../../hooks/useApp';
|
||||
import { resolveStandaloneSettingsPath } from '../../../settings-app/runtimePaths';
|
||||
import {
|
||||
filterRenderableSettings,
|
||||
filterVisibleSettings,
|
||||
@@ -66,109 +65,6 @@ const topbarActionTriggerClassName = css`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
type TopbarSettingsAppLike = Pick<
|
||||
BaseApplication<any>,
|
||||
'name' | 'router' | 'getPublicPath' | 'getHref' | 'layoutManager'
|
||||
>;
|
||||
|
||||
const normalizeTopbarPath = (pathname?: string) => {
|
||||
const trimmed = pathname?.trim();
|
||||
if (!trimmed || trimmed === '/') {
|
||||
return '/';
|
||||
}
|
||||
return `/${trimmed.replace(/^\/+/, '')}`;
|
||||
};
|
||||
|
||||
const normalizeTopbarBasePath = (pathname?: string) => {
|
||||
const normalized = normalizeTopbarPath(pathname).replace(/\/+$/, '');
|
||||
return normalized === '' || normalized === '/' ? '' : normalized;
|
||||
};
|
||||
|
||||
const getTopbarRouterBasePath = (app: TopbarSettingsAppLike | undefined) => {
|
||||
return app?.router?.getBasename?.() || app?.router?.basename || app?.getPublicPath?.() || '';
|
||||
};
|
||||
|
||||
const stripTopbarRouterBasePath = (pathname: string, basename?: string) => {
|
||||
const normalizedPath = normalizeTopbarPath(pathname);
|
||||
const normalizedBase = normalizeTopbarBasePath(basename);
|
||||
|
||||
if (!normalizedBase) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
if (normalizedPath === normalizedBase) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
if (normalizedPath.startsWith(`${normalizedBase}/`)) {
|
||||
return normalizeTopbarPath(normalizedPath.slice(normalizedBase.length));
|
||||
}
|
||||
|
||||
return normalizedPath;
|
||||
};
|
||||
|
||||
const getTopbarAppPath = (pathname: string) => {
|
||||
const normalizedPath = normalizeTopbarPath(pathname);
|
||||
const match = /^(.*?\/(?:apps|_app)\/[^/]+)(?=\/|$)/.exec(normalizedPath);
|
||||
const appPath = match?.[1] || '';
|
||||
|
||||
return {
|
||||
appPath,
|
||||
routePath: appPath ? normalizeTopbarPath(normalizedPath.slice(appPath.length)) : normalizedPath,
|
||||
};
|
||||
};
|
||||
|
||||
const getTopbarContextAppPath = (app: TopbarSettingsAppLike | undefined, basename?: string) => {
|
||||
const basenameAppPath = getTopbarAppPath(basename || '').appPath;
|
||||
if (basenameAppPath) {
|
||||
return basenameAppPath;
|
||||
}
|
||||
|
||||
const publicPathAppPath = getTopbarAppPath(app?.getPublicPath?.() || '').appPath;
|
||||
if (publicPathAppPath) {
|
||||
return publicPathAppPath;
|
||||
}
|
||||
|
||||
if (app?.name && app.name !== 'main' && app.getHref) {
|
||||
return normalizeTopbarBasePath(app.getHref('/'));
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const prependTopbarAppPath = (pathname: string, appPath: string) => {
|
||||
const normalizedPath = normalizeTopbarPath(pathname);
|
||||
|
||||
if (!appPath || normalizedPath === appPath || normalizedPath.startsWith(`${appPath}/`)) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
return `${appPath}${normalizedPath}`;
|
||||
};
|
||||
|
||||
const isAdminRuntimePath = (pathname: string) => {
|
||||
return pathname === '/admin' || pathname.startsWith('/admin/');
|
||||
};
|
||||
|
||||
const getTopbarAdminRoutePath = (app: TopbarSettingsAppLike | undefined) => {
|
||||
try {
|
||||
return app?.layoutManager?.getLayout?.('admin')?.routePath;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const buildTopbarDocumentHref = (targetPath: string, basename?: string) => {
|
||||
const normalizedTarget = normalizeTopbarPath(targetPath);
|
||||
const normalizedBase = normalizeTopbarBasePath(basename);
|
||||
|
||||
if (!normalizedBase || normalizedTarget === normalizedBase || normalizedTarget.startsWith(`${normalizedBase}/`)) {
|
||||
return normalizedTarget;
|
||||
}
|
||||
|
||||
return `${normalizedBase}${normalizedTarget}`;
|
||||
};
|
||||
|
||||
function TopbarExternalSettingsLabel(props: { title: React.ReactNode; link: string }) {
|
||||
return (
|
||||
<div
|
||||
@@ -185,39 +81,13 @@ function TopbarInternalSettingsLabel(props: { title: React.ReactNode; path?: str
|
||||
const app = useApp();
|
||||
const location = useLocation();
|
||||
const targetPath = props.path || '/admin/settings';
|
||||
const basename = getTopbarRouterBasePath(app);
|
||||
const currentPath = stripTopbarRouterBasePath(location.pathname, basename);
|
||||
const currentLocationAppPath = getTopbarAppPath(currentPath);
|
||||
const currentAppPath = currentLocationAppPath.appPath || getTopbarContextAppPath(app, basename);
|
||||
const currentRoutePath = currentLocationAppPath.appPath ? currentLocationAppPath.routePath : currentPath;
|
||||
const targetPathInCurrentApp = prependTopbarAppPath(stripTopbarRouterBasePath(targetPath, basename), currentAppPath);
|
||||
|
||||
if (currentAppPath) {
|
||||
const href = buildTopbarDocumentHref(targetPathInCurrentApp, basename);
|
||||
const shouldOpenInNewWindow = shouldOpenAdminRouteInNewWindow({
|
||||
currentPathname: currentPath,
|
||||
targetPathname: targetPathInCurrentApp,
|
||||
basePath: basename,
|
||||
adminRoutePath: getTopbarAdminRoutePath(app),
|
||||
});
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={shouldOpenInNewWindow ? '_blank' : undefined}
|
||||
rel={shouldOpenInNewWindow ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{props.title}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAdminRuntimePath(currentRoutePath)) {
|
||||
return <Link to={targetPathInCurrentApp}>{props.title}</Link>;
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={buildTopbarDocumentHref(targetPath, basename)} target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
href={resolveStandaloneSettingsPath(app, targetPath, location.pathname)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{props.title}
|
||||
</a>
|
||||
);
|
||||
@@ -238,66 +108,68 @@ export function getTopbarPluginSettingsItems(options: {
|
||||
}): NonNullable<MenuProps['items']> {
|
||||
const { settings, canManagePlugins, t } = options;
|
||||
const topLevelSettings = filterVisibleSettings(filterRenderableSettings(settings));
|
||||
const pluginManagerSetting = topLevelSettings.find((item) => item.name === PLUGIN_MANAGER_SETTING_NAME);
|
||||
const primarySettings = sortTopLevelSettings(
|
||||
topLevelSettings
|
||||
.filter(
|
||||
(item) =>
|
||||
((item.sort || 0) < 0 || item.name === PLUGIN_MANAGER_SETTING_NAME) &&
|
||||
(item.name !== PLUGIN_MANAGER_SETTING_NAME || canManagePlugins),
|
||||
)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: undefined,
|
||||
})),
|
||||
);
|
||||
const settingsByKey = new Map<string, PluginSettingsPageType>();
|
||||
const normalSettings = sortTopLevelSettings(
|
||||
topLevelSettings
|
||||
.filter((item) => item.name !== PLUGIN_MANAGER_SETTING_NAME)
|
||||
.filter((item) => (item.sort || 0) >= 0 && item.name !== PLUGIN_MANAGER_SETTING_NAME)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: undefined,
|
||||
})),
|
||||
);
|
||||
|
||||
normalSettings.forEach((item) => {
|
||||
[...primarySettings, ...normalSettings].forEach((item) => {
|
||||
settingsByKey.set(item.key, item);
|
||||
});
|
||||
|
||||
const orderedSettings = (getMenuItems(normalSettings) || []).map((item: any) => {
|
||||
if (item?.type === 'divider') {
|
||||
return item;
|
||||
}
|
||||
const buildMenuItems = (targetSettings: PluginSettingsPageType[]) =>
|
||||
(getMenuItems(targetSettings) || []).map((item: any) => {
|
||||
if (item?.type === 'divider') {
|
||||
return item;
|
||||
}
|
||||
|
||||
const matchedSetting = settingsByKey.get(String(item.key));
|
||||
const targetPath = matchedSetting?.path;
|
||||
const targetLink = matchedSetting?.link;
|
||||
const targetTitle = matchedSetting?.title || item.title;
|
||||
const matchedSetting = settingsByKey.get(String(item.key));
|
||||
const targetPath = matchedSetting?.path;
|
||||
const targetLink = matchedSetting?.link;
|
||||
const isPluginManager = matchedSetting?.name === PLUGIN_MANAGER_SETTING_NAME;
|
||||
const targetTitle = matchedSetting?.title || (isPluginManager ? t('Plugin manager') : item.title);
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
name: matchedSetting?.name,
|
||||
path: matchedSetting?.path,
|
||||
link: targetLink,
|
||||
title: targetTitle,
|
||||
icon: item.icon,
|
||||
label: targetLink ? (
|
||||
<TopbarExternalSettingsLabel title={targetTitle} link={targetLink} />
|
||||
) : (
|
||||
<TopbarInternalSettingsLabel title={targetTitle} path={targetPath} />
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const items: NonNullable<MenuProps['items']> = [];
|
||||
|
||||
if (canManagePlugins && pluginManagerSetting) {
|
||||
items.push({
|
||||
key: pluginManagerSetting.key,
|
||||
icon: pluginManagerSetting.icon || <ApiOutlined />,
|
||||
label: (
|
||||
<TopbarInternalSettingsLabel
|
||||
title={pluginManagerSetting.title || t('Plugin manager')}
|
||||
path={pluginManagerSetting.path}
|
||||
/>
|
||||
),
|
||||
return {
|
||||
key: item.key,
|
||||
name: matchedSetting?.name,
|
||||
path: matchedSetting?.path,
|
||||
link: targetLink,
|
||||
title: targetTitle,
|
||||
icon: isPluginManager ? matchedSetting?.icon || <ApiOutlined /> : item.icon,
|
||||
label: targetLink ? (
|
||||
<TopbarExternalSettingsLabel title={targetTitle} link={targetLink} />
|
||||
) : (
|
||||
<TopbarInternalSettingsLabel title={targetTitle} path={targetPath} />
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (canManagePlugins && orderedSettings.length) {
|
||||
const primaryItems = buildMenuItems(primarySettings);
|
||||
const normalItems = buildMenuItems(normalSettings);
|
||||
const items: NonNullable<MenuProps['items']> = [...primaryItems];
|
||||
|
||||
if (primaryItems.length && normalItems.length) {
|
||||
items.push({ type: 'divider' });
|
||||
}
|
||||
|
||||
items.push(...orderedSettings);
|
||||
items.push(...normalItems);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,14 @@ export * from './PluginSettingsManager';
|
||||
export * from './layout-manager';
|
||||
export * from './hooks';
|
||||
export { default as languageCodes } from './locale/languageCodes';
|
||||
export * from './nocobase-buildin-plugin';
|
||||
export {
|
||||
CurrentUserContext,
|
||||
NocoBaseBuildInPlugin,
|
||||
NocoBaseBuildInPluginV2,
|
||||
useCurrentRoles,
|
||||
useCurrentUserContext,
|
||||
} from './nocobase-buildin-plugin';
|
||||
export type { CurrentRoleOption, CurrentUserState } from './nocobase-buildin-plugin';
|
||||
export { getRouteRuntimeVersion } from './utils/getRouteRuntimeVersion';
|
||||
export type { RouteRuntimeVersion } from './utils/getRouteRuntimeVersion';
|
||||
export * from './collection-field-interface/CollectionFieldInterface';
|
||||
|
||||
@@ -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 { useSyncExternalStore } from 'react';
|
||||
|
||||
export type CurrentUserAuthStatus = 'unknown' | 'authenticated' | 'unauthenticated' | 'redirecting';
|
||||
|
||||
type AuthStatusStore = {
|
||||
getSnapshot: () => CurrentUserAuthStatus;
|
||||
set: (status: CurrentUserAuthStatus) => void;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
};
|
||||
|
||||
const stores = new WeakMap<object, AuthStatusStore>();
|
||||
|
||||
function createAuthStatusStore(): AuthStatusStore {
|
||||
let status: CurrentUserAuthStatus = 'unknown';
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
return {
|
||||
getSnapshot: () => status,
|
||||
set: (nextStatus) => {
|
||||
if (status === nextStatus) {
|
||||
return;
|
||||
}
|
||||
status = nextStatus;
|
||||
listeners.forEach((listener) => listener());
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getAuthStatusStore(app: object) {
|
||||
let store = stores.get(app);
|
||||
if (!store) {
|
||||
store = createAuthStatusStore();
|
||||
stores.set(app, store);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
export function setCurrentUserAuthStatus(app: object, status: CurrentUserAuthStatus) {
|
||||
getAuthStatusStore(app).set(status);
|
||||
}
|
||||
|
||||
export function useCurrentUserAuthStatus(app: object) {
|
||||
const store = getAuthStatusStore(app);
|
||||
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
||||
}
|
||||
@@ -9,21 +9,23 @@
|
||||
|
||||
import { createCollectionContextMeta, useFlowEngine } from '@nocobase/flow-engine';
|
||||
import React, { createContext, type FC, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useACLRoleContext } from '../acl';
|
||||
import type { Application } from '../Application';
|
||||
import { getCurrentV2RedirectPath, getDefaultV2AdminRedirectPath } from '../authRedirect';
|
||||
import { getCurrentV2RedirectPath, redirectToV2Signin } from '../authRedirect';
|
||||
import { AppNotFound } from '../components';
|
||||
import { PluginFlowEngine } from '../flow';
|
||||
import {
|
||||
ADMIN_LAYOUT_MODEL_UID,
|
||||
AdminLayoutMenuItemModel,
|
||||
AdminLayoutModel,
|
||||
AppSwitcherActionPanelModel,
|
||||
} from '../flow/admin-shell/admin-layout';
|
||||
import { useApp } from '../hooks/useApp';
|
||||
import { Plugin } from '../Plugin';
|
||||
import type { PluginClass } from '../PluginManager';
|
||||
import { AdminSettingsLayoutModel } from '../settings-center';
|
||||
import { SettingsDocumentRedirect } from '../settings-app/SettingsDocumentRedirect';
|
||||
import { type CurrentUserAuthStatus, setCurrentUserAuthStatus } from './currentUserAuthStatus';
|
||||
import { LocalePlugin } from './plugins/LocalePlugin';
|
||||
|
||||
export type CurrentUserState = {
|
||||
@@ -33,8 +35,6 @@ export type CurrentUserState = {
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
type CurrentUserAuthStatus = 'unknown' | 'authenticated' | 'unauthenticated' | 'redirecting';
|
||||
|
||||
type CurrentUserInternalState = CurrentUserState & {
|
||||
authStatus: CurrentUserAuthStatus;
|
||||
error?: Error | null;
|
||||
@@ -181,6 +181,19 @@ const DataSourceBootstrapProvider: FC = ({ children }) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
function redirectUnauthenticatedRoute(
|
||||
app: Application,
|
||||
location: { pathname: string; search?: string; hash?: string },
|
||||
navigate: ReturnType<typeof useNavigate>,
|
||||
) {
|
||||
const redirectPath = getCurrentV2RedirectPath(app, location);
|
||||
if (app.pluginSettingsManager.getRouteName('') === 'settings.') {
|
||||
redirectToV2Signin(app, redirectPath);
|
||||
return;
|
||||
}
|
||||
navigate(`/signin?redirect=${encodeURIComponent(redirectPath)}`, { replace: true });
|
||||
}
|
||||
|
||||
const CurrentUserProvider: FC = ({ children }) => {
|
||||
const app = useApp<Application>();
|
||||
const location = useLocation();
|
||||
@@ -234,9 +247,7 @@ const CurrentUserProvider: FC = ({ children }) => {
|
||||
if (user?.id == null) {
|
||||
// 用 react-router navigate (虚拟跳转)而不是 location.replace, 这样如果有其他响应拦截器已经发起了 window.location.href 整页跳转(例如 2FA 插件接收到服务端 302 重定向), 真实跳转可以胜出 navigate, 不会被这里的 signin 重定向覆盖。
|
||||
setState({ loading: true, authStatus: 'unauthenticated', error: null });
|
||||
navigate(`/signin?redirect=${encodeURIComponent(getCurrentV2RedirectPath(app, locationRef.current))}`, {
|
||||
replace: true,
|
||||
});
|
||||
redirectUnauthenticatedRoute(app, locationRef.current, navigate);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -277,9 +288,7 @@ const CurrentUserProvider: FC = ({ children }) => {
|
||||
const isAuthError = errorLike?.response?.status === 401 || errorLike?.status === 401;
|
||||
if (isAuthError) {
|
||||
setState({ loading: true, authStatus: 'unauthenticated', error: null });
|
||||
navigate(`/signin?redirect=${encodeURIComponent(getCurrentV2RedirectPath(app, locationRef.current))}`, {
|
||||
replace: true,
|
||||
});
|
||||
redirectUnauthenticatedRoute(app, locationRef.current, navigate);
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
@@ -297,6 +306,10 @@ const CurrentUserProvider: FC = ({ children }) => {
|
||||
};
|
||||
}, [app, authCheckRouteState, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentUserAuthStatus(app, state.authStatus);
|
||||
}, [app, state.authStatus]);
|
||||
|
||||
if (state.error) {
|
||||
throw state.error;
|
||||
}
|
||||
@@ -310,19 +323,6 @@ const CurrentUserProvider: FC = ({ children }) => {
|
||||
|
||||
CurrentUserProvider.displayName = 'CurrentUserProvider';
|
||||
|
||||
const RootRedirect: FC = () => {
|
||||
const app = useApp<Application>();
|
||||
const hasToken = !!app?.apiClient?.auth?.token;
|
||||
const targetPath = getDefaultV2AdminRedirectPath(app);
|
||||
|
||||
if (!hasToken) {
|
||||
// 用 react-router <Navigate /> 而非 location.replace, 避免覆盖同时段其它响应拦截器触发的 window.location.href (例如 2FA 接收到服务端 302 时设置的整页跳转)。
|
||||
return <Navigate replace to={`/signin?redirect=${encodeURIComponent(targetPath)}`} />;
|
||||
}
|
||||
|
||||
return <Navigate replace to="/admin" />;
|
||||
};
|
||||
|
||||
/**
|
||||
* client-v2 使用的内建插件集合。
|
||||
*
|
||||
@@ -346,56 +346,11 @@ export class NocoBaseBuildInPlugin extends Plugin<any, Application> {
|
||||
AppSwitcherActionPanelModel,
|
||||
AdminSettingsLayoutModel,
|
||||
});
|
||||
this.app.layoutManager.registerLayout({
|
||||
routeName: 'admin',
|
||||
routePath: '/admin',
|
||||
uid: ADMIN_LAYOUT_MODEL_UID,
|
||||
layoutModelClass: 'AdminLayoutModel',
|
||||
});
|
||||
|
||||
this.app.pluginSettingsManager.addMenuItem({
|
||||
key: 'plugin-manager',
|
||||
title: this.app.i18n.t('Plugin manager'),
|
||||
icon: 'ApiOutlined',
|
||||
aclSnippet: 'pm',
|
||||
sort: -200,
|
||||
});
|
||||
this.app.pluginSettingsManager.addPageTabItem({
|
||||
menuKey: 'plugin-manager',
|
||||
key: 'index',
|
||||
title: this.app.i18n.t('Plugin manager'),
|
||||
componentLoader: () => import('../settings-center/plugin-manager'),
|
||||
aclSnippet: 'pm',
|
||||
sort: -200,
|
||||
});
|
||||
this.app.pluginSettingsManager.addMenuItem({
|
||||
key: 'system-settings',
|
||||
title: this.app.i18n.t('System settings'),
|
||||
icon: 'SettingOutlined',
|
||||
aclSnippet: 'pm.system-settings.system-settings',
|
||||
});
|
||||
this.app.pluginSettingsManager.addPageTabItem({
|
||||
menuKey: 'system-settings',
|
||||
key: 'index',
|
||||
title: this.app.i18n.t('System settings'),
|
||||
componentLoader: () => import('../settings-center/SystemSettingsPage'),
|
||||
aclSnippet: 'pm.system-settings.system-settings',
|
||||
});
|
||||
// Parent menu for security-related plugin settings (password policy, locked users, etc.). Registered here in the buildin plugin so any pro plugin can attach page tabs to `menuKey: 'security'` without each one re-registering the same parent.
|
||||
this.app.pluginSettingsManager.addMenuItem({
|
||||
key: 'security',
|
||||
title: this.app.i18n.t('Security'),
|
||||
icon: 'SafetyOutlined',
|
||||
aclSnippet: 'pm.security',
|
||||
});
|
||||
registerDefaultSettings(this.app);
|
||||
}
|
||||
|
||||
addRoutes() {
|
||||
this.router.add('root', {
|
||||
path: '/',
|
||||
element: <RootRedirect />,
|
||||
});
|
||||
|
||||
this.router.add('not-found', {
|
||||
path: '*',
|
||||
Component: AppNotFound,
|
||||
@@ -403,7 +358,7 @@ export class NocoBaseBuildInPlugin extends Plugin<any, Application> {
|
||||
|
||||
this.router.add('admin.settings', {
|
||||
path: '/admin/settings',
|
||||
componentLoader: () => import('../settings-center/AdminSettingsLayout'),
|
||||
Component: SettingsDocumentRedirect,
|
||||
});
|
||||
this.router.add('admin.settings.route-empty', {
|
||||
path: '*',
|
||||
@@ -419,4 +374,77 @@ export class NocoBaseBuildInPlugin extends Plugin<any, Application> {
|
||||
}
|
||||
}
|
||||
|
||||
function registerDefaultSettings(app: Application) {
|
||||
app.pluginSettingsManager.addMenuItem({
|
||||
key: 'plugin-manager',
|
||||
title: app.i18n.t('Plugin manager'),
|
||||
icon: 'ApiOutlined',
|
||||
aclSnippet: 'pm',
|
||||
sort: -200,
|
||||
});
|
||||
app.pluginSettingsManager.addPageTabItem({
|
||||
menuKey: 'plugin-manager',
|
||||
key: 'index',
|
||||
title: app.i18n.t('Plugin manager'),
|
||||
componentLoader: () => import('../settings-center/plugin-manager'),
|
||||
aclSnippet: 'pm',
|
||||
sort: -200,
|
||||
});
|
||||
app.pluginSettingsManager.addMenuItem({
|
||||
key: 'system-settings',
|
||||
title: app.i18n.t('System settings'),
|
||||
icon: 'SettingOutlined',
|
||||
aclSnippet: 'pm.system-settings.system-settings',
|
||||
});
|
||||
app.pluginSettingsManager.addPageTabItem({
|
||||
menuKey: 'system-settings',
|
||||
key: 'index',
|
||||
title: app.i18n.t('System settings'),
|
||||
componentLoader: () => import('../settings-center/SystemSettingsPage'),
|
||||
aclSnippet: 'pm.system-settings.system-settings',
|
||||
});
|
||||
// Parent menu for security-related plugin settings (password policy, locked users, etc.). Registered here in the buildin plugin so any pro plugin can attach page tabs to `menuKey: 'security'` without each one re-registering the same parent.
|
||||
app.pluginSettingsManager.addMenuItem({
|
||||
key: 'security',
|
||||
title: app.i18n.t('Security'),
|
||||
icon: 'SafetyOutlined',
|
||||
aclSnippet: 'pm.security',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal built-in runtime for the standalone Client V2 settings entry.
|
||||
* It intentionally shares the existing plugin lane and runtime providers but
|
||||
* does not register the Admin Layout.
|
||||
*/
|
||||
export class SettingsBuildInPlugin extends Plugin<any, Application> {
|
||||
async afterAdd() {
|
||||
await this.app.pm.add(PluginFlowEngine);
|
||||
await this.app.pm.add(LocalePlugin as unknown as PluginClass, { name: 'builtin-locale' });
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.app.use(CurrentUserProvider);
|
||||
this.app.use(DataSourceBootstrapProvider);
|
||||
this.app.flowEngine.registerModels({ AdminSettingsLayoutModel });
|
||||
|
||||
this.router.add('settings', {
|
||||
path: '/settings',
|
||||
authCheck: true,
|
||||
componentLoader: () => import('../settings-center/AdminSettingsLayout'),
|
||||
});
|
||||
this.router.add('settingsDetails', {
|
||||
path: '/settings',
|
||||
authCheck: true,
|
||||
Component: Outlet,
|
||||
});
|
||||
this.router.add('settings.route-empty', {
|
||||
path: '*',
|
||||
Component: Outlet,
|
||||
});
|
||||
|
||||
registerDefaultSettings(this.app);
|
||||
}
|
||||
}
|
||||
|
||||
export { NocoBaseBuildInPlugin as NocoBaseBuildInPluginV2 };
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { Application, type ApplicationOptions } from '../index';
|
||||
import { SettingsPluginSettingsManager } from './SettingsPluginSettingsManager';
|
||||
import { SettingsRouterManager } from './SettingsRouterManager';
|
||||
import { SettingsShell } from './SettingsShell';
|
||||
|
||||
export class SettingsApplication extends Application {
|
||||
protected addCustomProviders() {
|
||||
super.addCustomProviders();
|
||||
this.use(SettingsShell);
|
||||
}
|
||||
|
||||
protected createRouterManager(options: ApplicationOptions): SettingsRouterManager<this> {
|
||||
return new SettingsRouterManager<this>(options.router, this);
|
||||
}
|
||||
|
||||
protected createPluginSettingsManager(_options: ApplicationOptions): SettingsPluginSettingsManager<this> {
|
||||
return new SettingsPluginSettingsManager<this>(this);
|
||||
}
|
||||
}
|
||||
@@ -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 { SettingsBuildInPlugin } from '../nocobase-buildin-plugin';
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 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 React, { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useApp } from '../hooks/useApp';
|
||||
import { resolveStandaloneSettingsPath } from './runtimePaths';
|
||||
|
||||
export function SettingsDocumentRedirect() {
|
||||
const app = useApp();
|
||||
const location = useLocation();
|
||||
const sourcePath = `${location.pathname}${location.search}${location.hash}`;
|
||||
const targetPath = resolveStandaloneSettingsPath(app, sourcePath, location.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
window.location.replace(targetPath);
|
||||
}, [targetPath]);
|
||||
|
||||
return app.renderComponent('AppSpin');
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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 { BaseApplication } from '../BaseApplication';
|
||||
import { PluginSettingsManager } from '../PluginSettingsManager';
|
||||
import { resolveSettingsAppScopeWithinPublicPath } from './settingsDocumentPath';
|
||||
|
||||
const SETTINGS_ROUTE_PREFIX = 'settings.';
|
||||
const MAIN_SETTINGS_PATH_PREFIX = '/settings/';
|
||||
|
||||
export class SettingsPluginSettingsManager<
|
||||
TApp extends BaseApplication<any> = BaseApplication<any>,
|
||||
> extends PluginSettingsManager<TApp> {
|
||||
getRouteName(name: string) {
|
||||
return `${SETTINGS_ROUTE_PREFIX}${name}`;
|
||||
}
|
||||
|
||||
getRoutePath(name: string) {
|
||||
const appScope = resolveSettingsAppScopeWithinPublicPath(this.app.getPublicPath(), this.app.router.getBasename?.());
|
||||
const pathPrefix = appScope ? '/' : MAIN_SETTINGS_PATH_PREFIX;
|
||||
const separatorIndex = name.indexOf('.');
|
||||
const menuName = separatorIndex < 0 ? name : name.slice(0, separatorIndex);
|
||||
const pageName = separatorIndex < 0 ? undefined : name.slice(separatorIndex + 1);
|
||||
const menuPath = `${pathPrefix}${menuName}`;
|
||||
|
||||
if (!pageName || pageName === 'index') {
|
||||
return menuPath;
|
||||
}
|
||||
|
||||
return `${menuPath}/${pageName}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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 { BaseApplication } from '../BaseApplication';
|
||||
import { RouterManager, type RouteType } from '../RouterManager';
|
||||
import { resolveSettingsAppScopeWithinPublicPath } from './settingsDocumentPath';
|
||||
|
||||
function isSettingsOwnedRoute(name: string) {
|
||||
return (
|
||||
name === 'not-found' ||
|
||||
name === 'settings' ||
|
||||
name.startsWith('settings.') ||
|
||||
name === 'settingsDetails' ||
|
||||
name.startsWith('settingsDetails.') ||
|
||||
name === 'auth' ||
|
||||
name.startsWith('auth.') ||
|
||||
name === '2fa' ||
|
||||
name.startsWith('2fa.')
|
||||
);
|
||||
}
|
||||
|
||||
function isSettingsAuthenticationRoute(name: string) {
|
||||
return name === 'auth' || name.startsWith('auth.') || name === '2fa' || name.startsWith('2fa.');
|
||||
}
|
||||
|
||||
export class SettingsRouterManager<
|
||||
TApp extends BaseApplication<any> = BaseApplication<any>,
|
||||
> extends RouterManager<TApp> {
|
||||
private rebaseScopedSettingsRoute(route: RouteType) {
|
||||
const appScope = resolveSettingsAppScopeWithinPublicPath(this.app.getPublicPath(), this.getBasename());
|
||||
if (!route.path?.startsWith('/') || !appScope) {
|
||||
return route;
|
||||
}
|
||||
if (route.path === '/settings') {
|
||||
return {
|
||||
...route,
|
||||
path: '/',
|
||||
};
|
||||
}
|
||||
if (!route.path.startsWith('/settings/')) {
|
||||
return route;
|
||||
}
|
||||
return {
|
||||
...route,
|
||||
path: route.path.slice('/settings'.length),
|
||||
};
|
||||
}
|
||||
|
||||
private rebaseAuthenticationRoute(route: RouteType) {
|
||||
if (!route.path?.startsWith('/')) {
|
||||
return route;
|
||||
}
|
||||
|
||||
const settingsRoot = this.app.pluginSettingsManager.getRoutePath('').replace(/\/+$/, '');
|
||||
if (!settingsRoot) {
|
||||
return route;
|
||||
}
|
||||
if (route.path === settingsRoot || route.path.startsWith(`${settingsRoot}/`)) {
|
||||
return route;
|
||||
}
|
||||
|
||||
return {
|
||||
...route,
|
||||
path: `${settingsRoot}/${route.path.replace(/^\/+/, '')}`,
|
||||
};
|
||||
}
|
||||
|
||||
add(name: string, route: RouteType) {
|
||||
if (!isSettingsOwnedRoute(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scopedRoute = this.rebaseScopedSettingsRoute(route);
|
||||
const ownedRoute = isSettingsAuthenticationRoute(name) ? this.rebaseAuthenticationRoute(scopedRoute) : scopedRoute;
|
||||
|
||||
super.add(
|
||||
name,
|
||||
name === 'settingsDetails' || name.startsWith('settingsDetails.')
|
||||
? {
|
||||
...ownedRoute,
|
||||
authCheck: true,
|
||||
}
|
||||
: ownedRoute,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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 { FlowModelRenderer } from '@nocobase/flow-engine';
|
||||
import { ConfigProvider, Layout, theme as antdTheme, type ThemeConfig } from 'antd';
|
||||
import React, { type FC } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { HelpLite } from '../flow/admin-shell/admin-layout/HelpLite';
|
||||
import { NocoBaseLogo } from '../flow/admin-shell/admin-layout/NocoBaseLogo';
|
||||
import {
|
||||
USER_CENTER_ACTION_ID,
|
||||
type UserCenterTopbarActionModel,
|
||||
} from '../flow/models/topbar/UserCenterTopbarActionModel';
|
||||
import { useApp } from '../hooks/useApp';
|
||||
import { useCurrentUserAuthStatus } from '../nocobase-buildin-plugin/currentUserAuthStatus';
|
||||
|
||||
const settingsShellTheme: ThemeConfig = {
|
||||
components: {
|
||||
Layout: {
|
||||
headerBg: '#176CE1',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const rootStyle: React.CSSProperties = {
|
||||
height: '100vh',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const headerContentStyle: React.CSSProperties = {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
justifyContent: 'space-between',
|
||||
};
|
||||
|
||||
const actionsStyle: React.CSSProperties = {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
};
|
||||
|
||||
const workspaceStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const embedContainerStyle: React.CSSProperties = {
|
||||
flexShrink: 0,
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
width: 'fit-content',
|
||||
};
|
||||
|
||||
export const SettingsShell: FC = ({ children }) => {
|
||||
const app = useApp();
|
||||
const location = useLocation();
|
||||
const { token } = antdTheme.useToken();
|
||||
const authStatus = useCurrentUserAuthStatus(app);
|
||||
const isAuthenticationRoute = (app.router.matchRoutes(location.pathname) || []).some((match) => {
|
||||
const routeId = match.route.id;
|
||||
return routeId === 'auth' || routeId?.startsWith('auth.') || routeId === '2fa' || routeId?.startsWith('2fa.');
|
||||
});
|
||||
|
||||
if (isAuthenticationRoute) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const shouldShowHeader = authStatus === 'authenticated';
|
||||
const hasUserCenterModel = Boolean(app.flowEngine.getModelClass('UserCenterTopbarActionModel'));
|
||||
const userCenter = hasUserCenterModel
|
||||
? app.flowEngine.getModel<UserCenterTopbarActionModel>(`topbar-action-${USER_CENTER_ACTION_ID}`) ||
|
||||
app.flowEngine.createModel<UserCenterTopbarActionModel>({
|
||||
use: 'UserCenterTopbarActionModel',
|
||||
uid: `topbar-action-${USER_CENTER_ACTION_ID}`,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={settingsShellTheme}>
|
||||
<Layout style={rootStyle}>
|
||||
<Layout.Header
|
||||
style={{
|
||||
display: shouldShowHeader ? undefined : 'none',
|
||||
height: 46,
|
||||
lineHeight: '46px',
|
||||
paddingInline: token.paddingLG,
|
||||
}}
|
||||
>
|
||||
<div style={headerContentStyle}>
|
||||
<NocoBaseLogo />
|
||||
<div style={actionsStyle}>
|
||||
<HelpLite />
|
||||
{userCenter ? <FlowModelRenderer model={userCenter} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Layout.Header>
|
||||
<div style={workspaceStyle}>
|
||||
<Layout.Content style={contentStyle}>{children}</Layout.Content>
|
||||
<div id="nocobase-embed-container" style={embedContainerStyle} />
|
||||
</div>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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 { stripModernClientPrefix } from '../authRedirect';
|
||||
import type { BaseApplication } from '../BaseApplication';
|
||||
import {
|
||||
resolveSettingsAppScope,
|
||||
resolveSettingsAppScopeWithinPublicPath,
|
||||
resolveSettingsDocumentPath,
|
||||
} from './settingsDocumentPath';
|
||||
|
||||
type SettingsRuntimeApp = Pick<BaseApplication<any>, 'name'> & {
|
||||
getPublicPath?: () => string;
|
||||
router?: {
|
||||
basename?: string;
|
||||
getBasename?: () => string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
const LEGACY_EMAIL_OAUTH_PATH = '/admin/settings/mail/oauth2';
|
||||
|
||||
const ROUTE_MAPPINGS = [
|
||||
{ from: '/admin/ai/knowledge-base/detail', to: '/settings/ai/knowledge-base/detail' },
|
||||
{ from: '/admin/workflow/executions', to: '/settings/workflow/executions' },
|
||||
{ from: '/admin/workflow/workflows', to: '/settings/workflow/workflows' },
|
||||
{ from: '/admin/settings', to: '/settings' },
|
||||
{ from: '/settings', to: '/settings' },
|
||||
] as const;
|
||||
|
||||
function normalizePathname(pathname?: string) {
|
||||
const normalized = `/${String(pathname || '/').trim()}`.replace(/\/{2,}/g, '/');
|
||||
return normalized === '/' ? normalized : normalized.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function splitPathSuffix(pathLike: string) {
|
||||
const match = String(pathLike || '').match(/^([^?#]*)(.*)$/);
|
||||
return {
|
||||
pathname: normalizePathname(match?.[1]),
|
||||
suffix: match?.[2] || '',
|
||||
};
|
||||
}
|
||||
|
||||
function getRuntimeAppScope(app: SettingsRuntimeApp, pathname: string) {
|
||||
const basename = app.router?.getBasename?.() || app.router?.basename;
|
||||
const publicPath = app.getPublicPath?.() || '/';
|
||||
return (
|
||||
resolveSettingsAppScopeWithinPublicPath(publicPath, pathname) ||
|
||||
resolveSettingsAppScopeWithinPublicPath(publicPath, basename) ||
|
||||
(app.name && app.name !== 'main' ? resolveSettingsAppScope(`/apps/${app.name}`) : '')
|
||||
);
|
||||
}
|
||||
|
||||
function getRootPublicPath(app: SettingsRuntimeApp, appScope: string) {
|
||||
const basename = app.router?.getBasename?.() || app.router?.basename || '';
|
||||
const publicPath = app.getPublicPath?.();
|
||||
if (publicPath) {
|
||||
return normalizePathname(stripModernClientPrefix(publicPath));
|
||||
}
|
||||
|
||||
const normalizedBasename = normalizePathname(basename);
|
||||
const basenameWithoutAppScope =
|
||||
appScope && normalizedBasename.endsWith(appScope)
|
||||
? normalizedBasename.slice(0, -appScope.length)
|
||||
: normalizedBasename;
|
||||
return normalizePathname(stripModernClientPrefix(basenameWithoutAppScope));
|
||||
}
|
||||
|
||||
function findMappedRoute(pathname: string) {
|
||||
for (const mapping of ROUTE_MAPPINGS) {
|
||||
const index = pathname.indexOf(mapping.from);
|
||||
if (index < 0) {
|
||||
continue;
|
||||
}
|
||||
const tail = pathname.slice(index + mapping.from.length);
|
||||
if (tail && !tail.startsWith('/')) {
|
||||
continue;
|
||||
}
|
||||
return `${mapping.to}${tail}`;
|
||||
}
|
||||
return '/settings';
|
||||
}
|
||||
|
||||
export function resolveStandaloneSettingsPath(app: SettingsRuntimeApp, pathLike: string, contextPathname?: string) {
|
||||
const { pathname, suffix } = splitPathSuffix(pathLike);
|
||||
const appScope = getRuntimeAppScope(app, contextPathname || pathname);
|
||||
const rootPublicPath = getRootPublicPath(app, appScope).replace(/\/+$/, '');
|
||||
const documentBasePath = `${rootPublicPath}${appScope}`;
|
||||
const oauthIndex = pathname.indexOf(LEGACY_EMAIL_OAUTH_PATH);
|
||||
|
||||
if (oauthIndex >= 0) {
|
||||
const oauthTail = pathname.slice(oauthIndex + LEGACY_EMAIL_OAUTH_PATH.length);
|
||||
if (!oauthTail || oauthTail.startsWith('/')) {
|
||||
return `${documentBasePath}${LEGACY_EMAIL_OAUTH_PATH}${oauthTail}${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `${resolveSettingsDocumentPath(rootPublicPath, appScope, findMappedRoute(pathname))}${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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 type SettingsAppScope = '' | `/${'apps' | '_app'}/${string}`;
|
||||
|
||||
function ensureLeadingSlash(value: string) {
|
||||
return value.startsWith('/') ? value : `/${value}`;
|
||||
}
|
||||
|
||||
export function normalizeSettingsRootPublicPath(value?: string) {
|
||||
const normalized = ensureLeadingSlash(String(value || '/').trim() || '/').replace(/\/{2,}/g, '/');
|
||||
return normalized.endsWith('/') ? normalized : `${normalized}/`;
|
||||
}
|
||||
|
||||
export function resolveSettingsAppScope(pathname?: string): SettingsAppScope {
|
||||
const match = /\/(apps|_app)\/([^/?#]+)(?=\/|[?#]|$)/.exec(ensureLeadingSlash(String(pathname || '/')));
|
||||
return match ? (`/${match[1]}/${match[2]}` as SettingsAppScope) : '';
|
||||
}
|
||||
|
||||
export function resolveSettingsAppScopeWithinPublicPath(publicPath: string, pathname?: string): SettingsAppScope {
|
||||
const root = normalizeSettingsRootPublicPath(publicPath).replace(/\/+$/, '');
|
||||
const path = ensureLeadingSlash(String(pathname || '/').split(/[?#]/)[0]).replace(/\/{2,}/g, '/');
|
||||
const relativePath = root && (path === root || path.startsWith(`${root}/`)) ? path.slice(root.length) || '/' : path;
|
||||
const match = /^\/(?:settings\/)?(apps|_app)\/([^/]+)(?=\/|$)/.exec(relativePath);
|
||||
return match ? (`/${match[1]}/${match[2]}` as SettingsAppScope) : '';
|
||||
}
|
||||
|
||||
function normalizeSettingsRoutePath(value: string) {
|
||||
const normalized = ensureLeadingSlash(String(value || '/settings')).replace(/\/{2,}/g, '/');
|
||||
return normalized === '/' ? '/settings' : normalized.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function removeSettingsRouteRoot(settingsRoute: string) {
|
||||
if (settingsRoute === '/settings') {
|
||||
return '';
|
||||
}
|
||||
return settingsRoute.replace(/^\/settings(?=\/|$)/, '');
|
||||
}
|
||||
|
||||
export function resolveSettingsDocumentPath(
|
||||
rootPublicPath: string,
|
||||
appScope: SettingsAppScope,
|
||||
settingsRoutePath: string,
|
||||
) {
|
||||
const root = normalizeSettingsRootPublicPath(rootPublicPath);
|
||||
const rootPrefix = root === '/' ? '' : root.replace(/\/+$/, '');
|
||||
const settingsRoute = normalizeSettingsRoutePath(settingsRoutePath);
|
||||
if (!appScope) {
|
||||
return `${rootPrefix}${settingsRoute}`;
|
||||
}
|
||||
return `${rootPrefix}/settings${appScope}${removeSettingsRouteRoot(settingsRoute)}`;
|
||||
}
|
||||
|
||||
export function resolveSettingsDocumentBasename(rootPublicPath: string, appScope: SettingsAppScope) {
|
||||
const root = normalizeSettingsRootPublicPath(rootPublicPath);
|
||||
if (!appScope) {
|
||||
return root;
|
||||
}
|
||||
return normalizeSettingsRootPublicPath(`${root}settings${appScope}`);
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { ApiOutlined } from '@ant-design/icons';
|
||||
import { PageHeader } from '@ant-design/pro-layout';
|
||||
import { css } from '@emotion/css';
|
||||
import { FlowModelRenderer, useFlowEngine } from '@nocobase/flow-engine';
|
||||
@@ -16,7 +15,7 @@ import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Navigate, Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useACLRoleContext } from '../acl';
|
||||
import { ADMIN_SETTINGS_PATH, type PluginSettingsPageType } from '../PluginSettingsManager';
|
||||
import type { PluginSettingsPageType } from '../PluginSettingsManager';
|
||||
import { useApp } from '../hooks/useApp';
|
||||
import { AdminSettingsLayoutModel } from './AdminSettingsLayoutModel';
|
||||
import {
|
||||
@@ -69,7 +68,6 @@ export const InternalAdminSettingsLayout = () => {
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
const { snippets = [] } = useACLRoleContext();
|
||||
|
||||
const allSettings = useMemo(
|
||||
@@ -83,18 +81,16 @@ export const InternalAdminSettingsLayout = () => {
|
||||
),
|
||||
[app.pluginSettingsManager],
|
||||
);
|
||||
const pluginManagerSetting = useMemo(
|
||||
() => visibleSettings.find((item) => item.name === PLUGIN_MANAGER_SETTING_NAME) || null,
|
||||
// Negative-sort settings share the top management section; their sort value controls order within that section.
|
||||
const primarySettings = useMemo(
|
||||
() => sortTopLevelSettings(visibleSettings.filter((item) => (item.sort || 0) < 0)),
|
||||
[visibleSettings],
|
||||
);
|
||||
const normalSettings = useMemo(
|
||||
() => sortTopLevelSettings(visibleSettings.filter((item) => item.name !== PLUGIN_MANAGER_SETTING_NAME)),
|
||||
() => sortTopLevelSettings(visibleSettings.filter((item) => (item.sort || 0) >= 0)),
|
||||
[visibleSettings],
|
||||
);
|
||||
const allVisibleSettings = useMemo(
|
||||
() => (pluginManagerSetting ? [pluginManagerSetting, ...normalSettings] : normalSettings),
|
||||
[normalSettings, pluginManagerSetting],
|
||||
);
|
||||
const allVisibleSettings = useMemo(() => [...primarySettings, ...normalSettings], [normalSettings, primarySettings]);
|
||||
const registeredSettingsMapByPath = useMemo(() => createSettingsPathMap(allSettings), [allSettings]);
|
||||
const visibleSettingsMapByPath = useMemo(() => createSettingsPathMap(allVisibleSettings), [allVisibleSettings]);
|
||||
const currentSetting = useMemo(
|
||||
@@ -117,11 +113,17 @@ export const InternalAdminSettingsLayout = () => {
|
||||
}
|
||||
return allVisibleSettings.find((item) => item.name === currentSetting.topLevelName) || null;
|
||||
}, [allVisibleSettings, currentSetting]);
|
||||
const defaultSettingsPath = useMemo(() => getDefaultSettingsPath(allVisibleSettings), [allVisibleSettings]);
|
||||
const defaultSettingsPath = useMemo(() => {
|
||||
const preferredPrimarySettings = primarySettings.filter((item) => item.name !== PLUGIN_MANAGER_SETTING_NAME);
|
||||
|
||||
return getDefaultSettingsPath(preferredPrimarySettings) || getDefaultSettingsPath(allVisibleSettings);
|
||||
}, [allVisibleSettings, primarySettings]);
|
||||
const currentVisibleTabs = useMemo(() => {
|
||||
return (currentVisibleTopLevelSetting?.children || []).filter((item) => !item.hidden) as PluginSettingsPageType[];
|
||||
}, [currentVisibleTopLevelSetting?.children]);
|
||||
const shouldShowTabs = currentVisibleTabs.length > 1 && currentVisibleTopLevelSetting?.showTabs !== false;
|
||||
const settingsRootPath = app.pluginSettingsManager.getRoutePath('');
|
||||
const settingsRootPathWithoutTrailingSlash = settingsRootPath.replace(/\/$/, '');
|
||||
|
||||
useEffect(() => {
|
||||
const nextTitle =
|
||||
@@ -136,14 +138,15 @@ export const InternalAdminSettingsLayout = () => {
|
||||
|
||||
const sidebarMenus = useMemo(() => {
|
||||
const items: any[] = [];
|
||||
const visiblePrimarySettings = primarySettings.filter(
|
||||
(item) => item.name !== PLUGIN_MANAGER_SETTING_NAME || snippets.includes('pm'),
|
||||
);
|
||||
const primaryMenuItems =
|
||||
getMenuItems(
|
||||
visiblePrimarySettings.map((item) => ({ ...item, children: undefined }) as PluginSettingsPageType),
|
||||
) || [];
|
||||
|
||||
if (pluginManagerSetting && snippets.includes('pm')) {
|
||||
items.push({
|
||||
key: pluginManagerSetting.name,
|
||||
icon: pluginManagerSetting.icon || <ApiOutlined />,
|
||||
label: pluginManagerSetting.label || t('Plugin manager'),
|
||||
});
|
||||
}
|
||||
items.push(...primaryMenuItems);
|
||||
|
||||
if (items.length && normalSettings.length) {
|
||||
items.push({ type: 'divider' });
|
||||
@@ -155,12 +158,12 @@ export const InternalAdminSettingsLayout = () => {
|
||||
items.push(...normalMenuItems);
|
||||
|
||||
return items;
|
||||
}, [normalSettings, pluginManagerSetting, snippets, t]);
|
||||
}, [normalSettings, primarySettings, snippets]);
|
||||
|
||||
const shouldRedirectToDefault =
|
||||
location.pathname === ADMIN_SETTINGS_PATH ||
|
||||
location.pathname === ADMIN_SETTINGS_PATH.replace(/\/$/, '') ||
|
||||
location.pathname === `${ADMIN_SETTINGS_PATH}index`;
|
||||
location.pathname === settingsRootPath ||
|
||||
location.pathname === settingsRootPathWithoutTrailingSlash ||
|
||||
location.pathname === `${settingsRootPath}index`;
|
||||
|
||||
if (shouldRedirectToDefault && defaultSettingsPath) {
|
||||
return <Navigate replace to={defaultSettingsPath} />;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/* eslint-env jest */
|
||||
|
||||
const { getUmiConfig } = require('../umiConfig');
|
||||
|
||||
describe('getUmiConfig Settings dev proxy', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.APP_PORT = '13001';
|
||||
process.env.APP_SETTINGS_PORT = '13004';
|
||||
process.env.APP_PUBLIC_PATH = '/nocobase/';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
test('proxies the outer Settings document path without taking over v1 settings', () => {
|
||||
const { proxy } = getUmiConfig();
|
||||
const rootProxy = proxy['/nocobase/settings{,/**}'];
|
||||
|
||||
expect(rootProxy).toMatchObject({ target: 'http://127.0.0.1:13004', changeOrigin: true, ws: true });
|
||||
expect(rootProxy.pathRewrite).toBeUndefined();
|
||||
expect(proxy['/nocobase/apps/*/settings{,/**}']).toBeUndefined();
|
||||
expect(proxy['/nocobase/_app/*/settings{,/**}']).toBeUndefined();
|
||||
expect(proxy['/nocobase/admin/settings']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ function getUmiConfig() {
|
||||
const {
|
||||
APP_PORT,
|
||||
APP_V2_PORT,
|
||||
APP_SETTINGS_PORT,
|
||||
API_BASE_URL,
|
||||
API_CLIENT_STORAGE_TYPE,
|
||||
API_CLIENT_STORAGE_PREFIX,
|
||||
@@ -57,6 +58,29 @@ function getUmiConfig() {
|
||||
};
|
||||
}
|
||||
|
||||
function getSettingsProxy() {
|
||||
if (!APP_SETTINGS_PORT) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const settingsTarget = `http://127.0.0.1:${APP_SETTINGS_PORT}`;
|
||||
const settingsPath = `${normalizedAppPublicPath}settings`;
|
||||
const createProxyOptions = () => ({
|
||||
target: settingsTarget,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
onProxyReq: (proxyReq, req) => {
|
||||
if (req?.ip) {
|
||||
proxyReq.setHeader('X-Forwarded-For', req.ip);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
[`${settingsPath}{,/**}`]: createProxyOptions(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
alias: getPackagePaths().reduce((memo, item) => {
|
||||
memo[item[0]] = item[1];
|
||||
@@ -100,6 +124,8 @@ function getUmiConfig() {
|
||||
...getLocalStorageProxy(),
|
||||
// v2 shell dev server proxy
|
||||
...getClientV2Proxy(),
|
||||
// standalone Settings dev server proxy
|
||||
...getSettingsProxy(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,6 +148,36 @@ describe('api-client', () => {
|
||||
expect(response?.data).toMatchObject({ data: { synced: true } });
|
||||
});
|
||||
|
||||
test.each([
|
||||
['https://example.com/settings/forgot-password?name=basic', 'https://example.com/settings'],
|
||||
['https://example.com/settings/apps/demo/forgot-password?name=basic', 'https://example.com/settings/apps/demo'],
|
||||
])('lostPassword derives the reset link base from the standalone Settings route', async (href, baseURL) => {
|
||||
Object.defineProperty(globalThis.window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
protocol: 'https:',
|
||||
href,
|
||||
search: '?name=basic',
|
||||
},
|
||||
});
|
||||
const api = new APIClient({
|
||||
baseURL: 'https://example.com/api',
|
||||
});
|
||||
const mock = new MockAdapter(api.axios);
|
||||
mock.onPost('auth:lostPassword').reply((config) => {
|
||||
expect(JSON.parse(config.data)).toEqual({
|
||||
email: 'user@example.com',
|
||||
baseURL,
|
||||
});
|
||||
expect(config.headers?.['X-Authenticator']).toBe('basic');
|
||||
return [204];
|
||||
});
|
||||
|
||||
const response = await api.auth.lostPassword({ email: 'user@example.com' });
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
|
||||
test('set token', async () => {
|
||||
const api = new APIClient({
|
||||
baseURL: 'https://localhost:8000/api',
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 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 { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { supertest } from '@nocobase/test';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { AppSupervisor } from '../app-supervisor';
|
||||
import { Gateway } from '../gateway';
|
||||
|
||||
const originalEnvironment = {
|
||||
APP_PACKAGE_ROOT: process.env.APP_PACKAGE_ROOT,
|
||||
APP_PUBLIC_PATH: process.env.APP_PUBLIC_PATH,
|
||||
APP_MODERN_CLIENT_PREFIX: process.env.APP_MODERN_CLIENT_PREFIX,
|
||||
API_BASE_PATH: process.env.API_BASE_PATH,
|
||||
CDN_BASE_URL: process.env.CDN_BASE_URL,
|
||||
};
|
||||
|
||||
function restoreEnvironmentValue(key: keyof typeof originalEnvironment) {
|
||||
const value = originalEnvironment[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
describe('gateway standalone settings client', () => {
|
||||
let gateway: Gateway;
|
||||
let packageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
packageRoot = await mkdtemp(path.join(os.tmpdir(), 'nocobase-gateway-settings-'));
|
||||
await mkdir(path.join(packageRoot, 'dist/client/v'), { recursive: true });
|
||||
await mkdir(path.join(packageRoot, 'dist/client/settings/assets'), { recursive: true });
|
||||
await writeFile(path.join(packageRoot, 'dist/client/index.html'), '<html>legacy-client</html>');
|
||||
await writeFile(path.join(packageRoot, 'dist/client/v/index.html'), '<html>modern-client</html>');
|
||||
await writeFile(
|
||||
path.join(packageRoot, 'dist/client/settings/index.html'),
|
||||
'<html><head><script src="/settings/assets/runtime.js" type="module"></script></head><body>settings-client</body></html>',
|
||||
);
|
||||
await writeFile(path.join(packageRoot, 'dist/client/settings/assets/runtime.js'), 'settings-runtime');
|
||||
|
||||
process.env.APP_PACKAGE_ROOT = packageRoot;
|
||||
process.env.APP_PUBLIC_PATH = '/nocobase/';
|
||||
process.env.APP_MODERN_CLIENT_PREFIX = 'modern';
|
||||
process.env.API_BASE_PATH = '/nocobase/api/';
|
||||
delete process.env.CDN_BASE_URL;
|
||||
gateway = Gateway.getInstance();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await gateway.destroy();
|
||||
await AppSupervisor.getInstance().destroy();
|
||||
await rm(packageRoot, { recursive: true, force: true });
|
||||
for (const key of Object.keys(originalEnvironment) as Array<keyof typeof originalEnvironment>) {
|
||||
restoreEnvironmentValue(key);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/nocobase/settings',
|
||||
'/nocobase/settings/signin',
|
||||
'/nocobase/settings/signup',
|
||||
'/nocobase/settings/forgot-password',
|
||||
'/nocobase/settings/reset-password?resetToken=test-token',
|
||||
'/nocobase/settings/2fa?redirect=%2Fnocobase%2Fsettings%2Fworkflow',
|
||||
'/nocobase/settings/workflow',
|
||||
'/nocobase/settings/apps/analytics/signin',
|
||||
'/nocobase/settings/apps/analytics/workflow/workflows/42',
|
||||
'/nocobase/settings/_app/analytics/reset-password?resetToken=test-token',
|
||||
'/nocobase/settings/_app/analytics/ai/knowledge-base/detail/orders/documents',
|
||||
])('serves the settings HTML for %s', async (requestPath) => {
|
||||
const response = await supertest.agent(gateway.getCallback()).get(requestPath);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['cache-control']).toBe('no-store');
|
||||
expect(response.text).toContain('settings-client');
|
||||
expect(response.text).not.toContain('legacy-client');
|
||||
});
|
||||
|
||||
it('serves standalone settings assets from the isolated output directory', async () => {
|
||||
const response = await supertest.agent(gateway.getCallback()).get('/nocobase/settings/assets/runtime.js');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable');
|
||||
expect(response.text).toBe('settings-runtime');
|
||||
});
|
||||
|
||||
it('injects the app public path and rewrites assets for public-path deployments', async () => {
|
||||
const response = await supertest
|
||||
.agent(gateway.getCallback())
|
||||
.get('/nocobase/settings/apps/analytics/workflow?tab=executions');
|
||||
|
||||
expect(response.text).toContain(`window['__nocobase_public_path__'] = "/nocobase/";`);
|
||||
expect(response.text).toContain('src="/nocobase/settings/assets/runtime.js"');
|
||||
});
|
||||
|
||||
it('rewrites settings assets to the isolated CDN directory', async () => {
|
||||
process.env.CDN_BASE_URL = 'https://cdn.example.com/releases/42/';
|
||||
|
||||
const response = await supertest.agent(gateway.getCallback()).get('/nocobase/settings/workflow');
|
||||
|
||||
expect(response.text).toContain('src="https://cdn.example.com/releases/42/settings/assets/runtime.js"');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/nocobase/modern/admin/settings/workflow?tab=executions', '/nocobase/settings/workflow?tab=executions'],
|
||||
['/nocobase/modern/admin/workflow/workflows/42?from=list', '/nocobase/settings/workflow/workflows/42?from=list'],
|
||||
[
|
||||
'/nocobase/modern/admin/workflow/executions/99?from=workflow',
|
||||
'/nocobase/settings/workflow/executions/99?from=workflow',
|
||||
],
|
||||
[
|
||||
'/nocobase/modern/admin/ai/knowledge-base/detail/orders/documents?tab=files',
|
||||
'/nocobase/settings/ai/knowledge-base/detail/orders/documents?tab=files',
|
||||
],
|
||||
['/nocobase/modern/apps/analytics/admin/settings/workflow', '/nocobase/settings/apps/analytics/workflow'],
|
||||
[
|
||||
'/nocobase/modern/_app/analytics/admin/workflow/workflows/42',
|
||||
'/nocobase/settings/_app/analytics/workflow/workflows/42',
|
||||
],
|
||||
])('redirects the old v2 settings route %s to %s', async (requestPath, expectedLocation) => {
|
||||
const response = await supertest.agent(gateway.getCallback()).get(requestPath);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.location).toBe(expectedLocation);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'/nocobase/modern/admin/settings/mail/oauth2?code=main-code',
|
||||
'/nocobase/admin/settings/mail/oauth2?code=main-code',
|
||||
],
|
||||
[
|
||||
'/nocobase/modern/apps/analytics/admin/settings/mail/oauth2?code=sub-code',
|
||||
'/nocobase/apps/analytics/admin/settings/mail/oauth2?code=sub-code',
|
||||
],
|
||||
])('redirects the email OAuth callback %s to the legacy client', async (requestPath, expectedLocation) => {
|
||||
const response = await supertest.agent(gateway.getCallback()).get(requestPath);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.location).toBe(expectedLocation);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/nocobase/admin/settings/workflow',
|
||||
'/nocobase/admin/workflow/workflows/42',
|
||||
'/nocobase/admin/settings/mail/oauth2?code=legacy-code',
|
||||
'/nocobase/apps/analytics/settings/workflow',
|
||||
'/nocobase/_app/analytics/settings/workflow',
|
||||
])('keeps the v1 URL %s on the legacy HTML', async (requestPath) => {
|
||||
const response = await supertest.agent(gateway.getCallback()).get(requestPath);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toContain('legacy-client');
|
||||
expect(response.text).not.toContain('settings-client');
|
||||
});
|
||||
|
||||
it('keeps unrelated v2 routes on the modern HTML', async () => {
|
||||
const response = await supertest.agent(gateway.getCallback()).get('/nocobase/modern/admin/workflow/tasks');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toContain('modern-client');
|
||||
expect(response.text).not.toContain('settings-client');
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
injectRuntimeScript,
|
||||
MODERN_CLIENT_DIST_DIR,
|
||||
normalizeModernClientPrefix,
|
||||
resolveSettingsPublicPath,
|
||||
resolveV2PublicPath,
|
||||
rewriteSettingsAssetPublicPath,
|
||||
rewriteV2AssetPublicPath,
|
||||
} from '../gateway/utils';
|
||||
|
||||
@@ -31,6 +33,10 @@ describe('gateway utils', () => {
|
||||
expect(normalizeModernClientPrefix(undefined)).toBe(DIR);
|
||||
});
|
||||
|
||||
it('rejects the Settings route as a modern client prefix', () => {
|
||||
expect(() => normalizeModernClientPrefix('/settings/')).toThrow('APP_MODERN_CLIENT_PREFIX "settings" is reserved');
|
||||
});
|
||||
|
||||
it('should resolve modern client public path from app public path (default prefix)', () => {
|
||||
expect(resolveV2PublicPath('/')).toBe(`/${DIR}/`);
|
||||
expect(resolveV2PublicPath('/nocobase/')).toBe(`/nocobase/${DIR}/`);
|
||||
@@ -42,6 +48,13 @@ describe('gateway utils', () => {
|
||||
expect(resolveV2PublicPath('/nocobase/')).toBe('/nocobase/admin/');
|
||||
});
|
||||
|
||||
it('should resolve the standalone settings public path independently of the modern prefix', () => {
|
||||
process.env.APP_MODERN_CLIENT_PREFIX = '/modern/';
|
||||
|
||||
expect(resolveSettingsPublicPath('/')).toBe('/settings/');
|
||||
expect(resolveSettingsPublicPath('/nocobase/')).toBe('/nocobase/settings/');
|
||||
});
|
||||
|
||||
it('should rewrite modern asset paths for prefixed deployment', () => {
|
||||
const html = [
|
||||
`<script>window.__nocobase_public_path__=window.__nocobase_public_path__||"/${DIR}/"</script>`,
|
||||
@@ -62,6 +75,17 @@ describe('gateway utils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should rewrite standalone settings assets for public-path and CDN deployments', () => {
|
||||
const html = '<script src="/settings/assets/runtime.js" type="module"></script>';
|
||||
|
||||
expect(rewriteSettingsAssetPublicPath(html, '/nocobase/settings/')).toBe(
|
||||
'<script src="/nocobase/settings/assets/runtime.js" type="module"></script>',
|
||||
);
|
||||
expect(rewriteSettingsAssetPublicPath(html, 'https://cdn.example.com/releases/42/settings/')).toBe(
|
||||
'<script src="https://cdn.example.com/releases/42/settings/assets/runtime.js" type="module"></script>',
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep html unchanged for default modern public path', () => {
|
||||
const html = `<script src="/${DIR}/assets/runtime.js" type="module"></script>`;
|
||||
expect(rewriteV2AssetPublicPath(html, `/${DIR}/`)).toBe(html);
|
||||
|
||||
@@ -39,8 +39,11 @@ import {
|
||||
normalizeModernClientPrefix,
|
||||
normalizePortalAppName,
|
||||
resolvePublicPath,
|
||||
resolveSettingsPublicPath,
|
||||
resolveV2PublicPath,
|
||||
rewriteSettingsAssetPublicPath,
|
||||
rewriteV2AssetPublicPath,
|
||||
SETTINGS_CLIENT_DIST_DIR,
|
||||
} from './utils';
|
||||
import { WSServer } from './ws-server';
|
||||
import { isMainThread, workerData } from 'node:worker_threads';
|
||||
@@ -131,6 +134,7 @@ export class Gateway extends EventEmitter {
|
||||
private host = '0.0.0.0';
|
||||
private socketPath = getSocketPath();
|
||||
private v2IndexTemplateCache: { file: string; mtimeMs: number; html: string } | null = null;
|
||||
private settingsIndexTemplateCache: { file: string; mtimeMs: number; html: string } | null = null;
|
||||
private terminating = false;
|
||||
|
||||
private getOriginalRequestUrl(req: IncomingMessage) {
|
||||
@@ -185,6 +189,7 @@ export class Gateway extends EventEmitter {
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
normalizeModernClientPrefix(process.env.APP_MODERN_CLIENT_PREFIX);
|
||||
this.reset();
|
||||
process.once('SIGTERM', this.onTerminate);
|
||||
process.once('SIGINT', this.onTerminate);
|
||||
@@ -369,6 +374,104 @@ export class Gateway extends EventEmitter {
|
||||
return resolvePublicPath(process.env.APP_PUBLIC_PATH || '/');
|
||||
}
|
||||
|
||||
private getSettingsPublicPath() {
|
||||
return resolveSettingsPublicPath(process.env.APP_PUBLIC_PATH || '/');
|
||||
}
|
||||
|
||||
private getPathWithinAppPublicPath(pathname: string) {
|
||||
const appPublicPath = this.getAppPublicPath();
|
||||
if (appPublicPath === '/') {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
const appPublicPathWithoutSlash = appPublicPath.slice(0, -1);
|
||||
if (pathname === appPublicPathWithoutSlash) {
|
||||
return '/';
|
||||
}
|
||||
if (!pathname.startsWith(appPublicPath)) {
|
||||
return null;
|
||||
}
|
||||
return pathname.slice(appPublicPath.length - 1);
|
||||
}
|
||||
|
||||
private isSettingsRequest(pathname: string) {
|
||||
const appPath = this.getPathWithinAppPublicPath(pathname);
|
||||
if (!appPath) {
|
||||
return false;
|
||||
}
|
||||
return /^\/settings(?:\/|$)/.test(appPath);
|
||||
}
|
||||
|
||||
private isSettingsIndexRequest(pathname: string) {
|
||||
if (!this.isSettingsRequest(pathname)) {
|
||||
return false;
|
||||
}
|
||||
if (pathname.endsWith('/index.html')) {
|
||||
return true;
|
||||
}
|
||||
return !extname(pathname);
|
||||
}
|
||||
|
||||
private isSettingsAssetsRequest(pathname: string) {
|
||||
const appPath = this.getPathWithinAppPublicPath(pathname);
|
||||
return appPath ? /^\/settings\/assets\//.test(appPath) : false;
|
||||
}
|
||||
|
||||
private resolveLegacyV2SettingsRedirect(pathname: string) {
|
||||
const appPath = this.getPathWithinAppPublicPath(pathname);
|
||||
if (!appPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modernPrefix = normalizeModernClientPrefix(process.env.APP_MODERN_CLIENT_PREFIX);
|
||||
const escapedModernPrefix = modernPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = appPath.match(new RegExp(`^/${escapedModernPrefix}((?:/(?:apps|_app)/[^/]+)?)(/admin(?:/.*)?)$`));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const appScope = match[1] || '';
|
||||
const legacyPath = match[2];
|
||||
const mappings = [
|
||||
{
|
||||
from: '/admin/settings/mail/oauth2',
|
||||
to: '/admin/settings/mail/oauth2',
|
||||
},
|
||||
{
|
||||
from: '/admin/ai/knowledge-base/detail',
|
||||
to: '/settings/ai/knowledge-base/detail',
|
||||
},
|
||||
{
|
||||
from: '/admin/workflow/executions',
|
||||
to: '/settings/workflow/executions',
|
||||
},
|
||||
{
|
||||
from: '/admin/workflow/workflows',
|
||||
to: '/settings/workflow/workflows',
|
||||
},
|
||||
{
|
||||
from: '/admin/settings',
|
||||
to: '/settings',
|
||||
},
|
||||
];
|
||||
|
||||
for (const { from, to } of mappings) {
|
||||
if (legacyPath !== from && !legacyPath.startsWith(`${from}/`)) {
|
||||
continue;
|
||||
}
|
||||
const appPublicPath = this.getAppPublicPath().replace(/\/$/, '');
|
||||
const targetPath =
|
||||
from === '/admin/settings/mail/oauth2'
|
||||
? `${appScope}${to}`
|
||||
: appScope
|
||||
? `/settings${appScope}${to.replace(/^\/settings(?=\/|$)/, '')}`
|
||||
: to;
|
||||
return `${appPublicPath}${targetPath}${legacyPath.slice(from.length)}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getPortalRootPublicPath() {
|
||||
return `${this.getAppPublicPath().replace(/\/$/, '')}/${PORTAL_CLIENT_PREFIX}/`;
|
||||
}
|
||||
@@ -527,6 +630,70 @@ export class Gateway extends EventEmitter {
|
||||
return injectRuntimeScript(html, this.getV2RuntimeConfigScript());
|
||||
}
|
||||
|
||||
private getSettingsRuntimeConfig() {
|
||||
return {
|
||||
__nocobase_public_path__: this.getAppPublicPath(),
|
||||
__nocobase_modern_client_prefix__: normalizeModernClientPrefix(process.env.APP_MODERN_CLIENT_PREFIX),
|
||||
__webpack_public_path__: process.env.CDN_BASE_URL ? `${process.env.CDN_BASE_URL.replace(/\/+$/, '')}/` : '',
|
||||
__nocobase_api_base_url__: process.env.API_BASE_URL || process.env.API_BASE_PATH,
|
||||
__nocobase_api_client_storage_prefix__: process.env.API_CLIENT_STORAGE_PREFIX,
|
||||
__nocobase_api_client_storage_type__: process.env.API_CLIENT_STORAGE_TYPE,
|
||||
__nocobase_api_client_share_token__: process.env.API_CLIENT_SHARE_TOKEN === 'true',
|
||||
__nocobase_ws_url__: process.env.WEBSOCKET_URL || '',
|
||||
__nocobase_ws_path__: process.env.WS_PATH,
|
||||
__nocobase_app_dev__: process.env.NOCOBASE_APP_DEV === 'true',
|
||||
__esm_cdn_base_url__: process.env.ESM_CDN_BASE_URL || 'https://esm.sh',
|
||||
__esm_cdn_suffix__: process.env.ESM_CDN_SUFFIX || '',
|
||||
};
|
||||
}
|
||||
|
||||
private getSettingsRuntimeConfigScript() {
|
||||
const scriptContent = Object.entries(this.getSettingsRuntimeConfig())
|
||||
.map(([key, value]) => `window['${key}'] = ${JSON.stringify(value)};`)
|
||||
.join('\n');
|
||||
|
||||
return `<script>${scriptContent}</script>`;
|
||||
}
|
||||
|
||||
private getSettingsAssetPublicPath() {
|
||||
if (process.env.CDN_BASE_URL) {
|
||||
return `${process.env.CDN_BASE_URL.replace(/\/+$/, '')}/${SETTINGS_CLIENT_DIST_DIR}/`;
|
||||
}
|
||||
return this.getSettingsPublicPath();
|
||||
}
|
||||
|
||||
private getSettingsIndexTemplate() {
|
||||
const file = `${process.env.APP_PACKAGE_ROOT}/dist/client/${SETTINGS_CLIENT_DIST_DIR}/index.html`;
|
||||
if (!fs.existsSync(file)) {
|
||||
return null;
|
||||
}
|
||||
const stat = fs.statSync(file);
|
||||
if (
|
||||
this.settingsIndexTemplateCache &&
|
||||
this.settingsIndexTemplateCache.file === file &&
|
||||
this.settingsIndexTemplateCache.mtimeMs === stat.mtimeMs
|
||||
) {
|
||||
return this.settingsIndexTemplateCache.html;
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(file, 'utf-8');
|
||||
this.settingsIndexTemplateCache = {
|
||||
file,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
html,
|
||||
};
|
||||
return html;
|
||||
}
|
||||
|
||||
private renderSettingsIndexHtml() {
|
||||
const template = this.getSettingsIndexTemplate();
|
||||
if (!template) {
|
||||
return null;
|
||||
}
|
||||
const html = rewriteSettingsAssetPublicPath(template, this.getSettingsAssetPublicPath());
|
||||
return injectRuntimeScript(html, this.getSettingsRuntimeConfigScript());
|
||||
}
|
||||
|
||||
async requestHandler(req: IncomingMessage, res: ServerResponse) {
|
||||
const { pathname, search } = parse(req.url);
|
||||
const { PLUGIN_STATICS_PATH } = process.env;
|
||||
@@ -545,6 +712,14 @@ export class Gateway extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsRedirect = this.resolveLegacyV2SettingsRedirect(pathname);
|
||||
if (settingsRedirect) {
|
||||
res.statusCode = 302;
|
||||
res.setHeader('Location', `${settingsRedirect}${search || ''}`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const supervisor = AppSupervisor.getInstance();
|
||||
let handleApp = 'main';
|
||||
try {
|
||||
@@ -621,6 +796,35 @@ export class Gateway extends EventEmitter {
|
||||
const isFilesRequest = Boolean(getFileAccessRestPath(pathname, APP_PUBLIC_PATH));
|
||||
|
||||
if (!pathname.startsWith(process.env.API_BASE_PATH) && !isFilesRequest) {
|
||||
if (this.isSettingsRequest(pathname)) {
|
||||
if (handleApp !== 'main') {
|
||||
const isProxy = await this.proxyRequestToSubApp(supervisor, handleApp, req, res);
|
||||
if (isProxy) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isSettingsIndexRequest(pathname)) {
|
||||
const settingsHtml = this.renderSettingsIndexHtml();
|
||||
if (settingsHtml) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.end(settingsHtml);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isSettingsAssetsRequest(pathname)) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
|
||||
req.url = req.url.substring(APP_PUBLIC_PATH.length - 1);
|
||||
await compress(req, res);
|
||||
return handler(req, res, {
|
||||
public: `${process.env.APP_PACKAGE_ROOT}/dist/client`,
|
||||
});
|
||||
}
|
||||
|
||||
const portalMatch = this.getPortalMatch(pathname);
|
||||
if (portalMatch) {
|
||||
if (handleApp !== 'main' && handleApp !== portalMatch.appName) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { IncomingRequest } from '.';
|
||||
// of this default lives in packages/core/cli-v1/src/util.js
|
||||
// (DEFAULT_MODERN_CLIENT_PREFIX). See docs/adr/0001-modern-client-prefix.md.
|
||||
export const MODERN_CLIENT_DIST_DIR = 'v';
|
||||
export const SETTINGS_CLIENT_DIST_DIR = 'settings';
|
||||
export const PORTAL_CLIENT_PREFIX = 'x';
|
||||
export const DEFAULT_PORTAL_APP_NAME = 'main';
|
||||
export const DEFAULT_PORTAL_NAME = 'admin';
|
||||
@@ -32,7 +33,11 @@ export function normalizeModernClientPrefix(value?: string) {
|
||||
const segment = String(value || '')
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
return segment || MODERN_CLIENT_DIST_DIR;
|
||||
const normalized = segment || MODERN_CLIENT_DIST_DIR;
|
||||
if (normalized === SETTINGS_CLIENT_DIST_DIR) {
|
||||
throw new Error('APP_MODERN_CLIENT_PREFIX "settings" is reserved for the standalone Settings application.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveV2PublicPath(appPublicPath = '/') {
|
||||
@@ -41,6 +46,11 @@ export function resolveV2PublicPath(appPublicPath = '/') {
|
||||
return `${publicPath.replace(/\/$/, '')}/${prefix}/`;
|
||||
}
|
||||
|
||||
export function resolveSettingsPublicPath(appPublicPath = '/') {
|
||||
const publicPath = resolvePublicPath(appPublicPath);
|
||||
return `${publicPath.replace(/\/$/, '')}/${SETTINGS_CLIENT_DIST_DIR}/`;
|
||||
}
|
||||
|
||||
export function normalizePortalName(value?: string) {
|
||||
const segment = String(value || '')
|
||||
.trim()
|
||||
@@ -77,6 +87,17 @@ export function rewriteV2AssetPublicPath(html: string, assetPublicPath: string)
|
||||
return html.replace(sentinelPattern, `$1${normalizedAssetPublicPath}`);
|
||||
}
|
||||
|
||||
export function rewriteSettingsAssetPublicPath(html: string, assetPublicPath: string) {
|
||||
const normalizedAssetPublicPath = ensureTrailingSlash(assetPublicPath);
|
||||
const sentinel = `/${SETTINGS_CLIENT_DIST_DIR}/`;
|
||||
if (normalizedAssetPublicPath === sentinel) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const sentinelPattern = new RegExp(`((?:src|href)=["'])${sentinel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g');
|
||||
return html.replace(sentinelPattern, `$1${normalizedAssetPublicPath}`);
|
||||
}
|
||||
|
||||
export function injectRuntimeScript(html: string, runtimeScript: string) {
|
||||
const browserCheckerScriptMatch = html.match(/<script\b[^>]*browser-checker\.js[^>]*><\/script>/i);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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 PluginAclClientV2 from '../plugin';
|
||||
|
||||
describe('PluginAclClientV2', () => {
|
||||
it('should not register the legacy Desktop routes permission tab in Client V2', async () => {
|
||||
const app = createMockClient({ plugins: [PluginAclClientV2] });
|
||||
|
||||
await app.load();
|
||||
|
||||
const plugin = app.pm.get(PluginAclClientV2);
|
||||
const tabs = plugin.settingsUI.getPermissionsTabs({
|
||||
activeKey: 'general',
|
||||
activeRole: null,
|
||||
currentUserRole: null,
|
||||
onRoleChange: vi.fn(),
|
||||
});
|
||||
|
||||
expect(tabs.map((tab) => tab.key)).toEqual(['general']);
|
||||
expect(tabs.map((tab) => tab.label)).not.toContain('Desktop routes');
|
||||
});
|
||||
});
|
||||
@@ -42,12 +42,6 @@ export class PluginAclClientV2 extends Plugin<any, Application> {
|
||||
sort: 10,
|
||||
componentLoader: () => import('./pages/permissions/SystemPermissionsTab'),
|
||||
});
|
||||
this.settingsUI.addPermissionsTab({
|
||||
key: 'menu',
|
||||
label: String(this.t('Desktop routes')),
|
||||
sort: 20,
|
||||
componentLoader: () => import('./pages/permissions/DesktopRoutesPermissionsTab'),
|
||||
});
|
||||
|
||||
this.flowEngine.registerModelLoaders({
|
||||
UIEditorTopbarActionModel: {
|
||||
|
||||
@@ -16,6 +16,8 @@ const navigateMock = vi.fn();
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
publicPath: '/v/',
|
||||
basename: '/v/apps/sub/',
|
||||
settingsRouteName: 'admin.settings.',
|
||||
settingsRouteRoot: '/admin/settings/',
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
@@ -35,6 +37,10 @@ vi.mock('@nocobase/client-v2', async (importOriginal) => {
|
||||
router: {
|
||||
getBasename: () => mockApp.basename,
|
||||
},
|
||||
pluginSettingsManager: {
|
||||
getRouteName: () => mockApp.settingsRouteName,
|
||||
getRoutePath: () => mockApp.settingsRouteRoot,
|
||||
},
|
||||
}),
|
||||
usePlugin: () => ({
|
||||
authTypes: {
|
||||
@@ -49,9 +55,11 @@ describe('SignInPage', () => {
|
||||
navigateMock.mockReset();
|
||||
mockApp.publicPath = '/v/';
|
||||
mockApp.basename = '/v/apps/sub/';
|
||||
mockApp.settingsRouteName = 'admin.settings.';
|
||||
mockApp.settingsRouteRoot = '/admin/settings/';
|
||||
});
|
||||
|
||||
it('normalizes empty redirect to the current v2 app admin path', () => {
|
||||
it('normalizes empty redirect to the current v2 app root for dynamic Portal landing', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/signin?redirect=']}>
|
||||
<SignInPage />
|
||||
@@ -61,7 +69,7 @@ describe('SignInPage', () => {
|
||||
expect(navigateMock).toHaveBeenCalledWith(
|
||||
{
|
||||
pathname: '/signin',
|
||||
search: '?redirect=%2Fv%2Fapps%2Fsub%2Fadmin%2F',
|
||||
search: '?redirect=%2Fv%2Fapps%2Fsub',
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
@@ -76,4 +84,25 @@ describe('SignInPage', () => {
|
||||
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes empty redirect to the current standalone Settings root', () => {
|
||||
mockApp.publicPath = '/nocobase/';
|
||||
mockApp.basename = '/nocobase/settings/apps/sub/';
|
||||
mockApp.settingsRouteName = 'settings.';
|
||||
mockApp.settingsRouteRoot = '/';
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/signin?redirect=']}>
|
||||
<SignInPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith(
|
||||
{
|
||||
pathname: '/signin',
|
||||
search: '?redirect=%2Fnocobase%2Fsettings%2Fapps%2Fsub',
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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 { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import BasicSignInForm from '../forms/BasicSignInForm';
|
||||
import BasicSignUpForm from '../forms/BasicSignUpForm';
|
||||
import ForgotPasswordPage from '../pages/ForgotPasswordPage';
|
||||
import ResetPasswordPage from '../pages/ResetPasswordPage';
|
||||
|
||||
const authenticator = {
|
||||
name: 'basic',
|
||||
authType: 'Email/Password',
|
||||
authTypeTitle: 'Password',
|
||||
options: {
|
||||
allowSignUp: true,
|
||||
enableResetPassword: true,
|
||||
signupForm: [],
|
||||
},
|
||||
};
|
||||
|
||||
const routePaths: Record<string, string> = {
|
||||
'auth.signin': '/settings/signin',
|
||||
'auth.signup': '/settings/signup',
|
||||
'auth.forgotPassword': '/settings/forgot-password',
|
||||
'auth.resetPassword': '/settings/reset-password',
|
||||
};
|
||||
|
||||
vi.mock('@nocobase/client-v2', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nocobase/client-v2')>();
|
||||
return {
|
||||
...actual,
|
||||
useApp: () => ({
|
||||
router: {
|
||||
get: (name: string) => ({ path: routePaths[name] }),
|
||||
},
|
||||
apiClient: {
|
||||
auth: {
|
||||
checkResetToken: vi.fn(() => new Promise(() => undefined)),
|
||||
lostPassword: vi.fn().mockResolvedValue(undefined),
|
||||
resetPassword: vi.fn().mockResolvedValue(undefined),
|
||||
signUp: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../authenticator', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../authenticator')>();
|
||||
return {
|
||||
...actual,
|
||||
useAuthenticator: () => authenticator,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useDocumentTitle: vi.fn(),
|
||||
useSignIn: () => ({ run: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('../locale', () => ({
|
||||
useAuthTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
describe('plugin-auth route-aware navigation', () => {
|
||||
it('uses the registered Settings routes from the basic sign-in form', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/settings/signin']}>
|
||||
<BasicSignInForm authenticator={authenticator} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Create an account' })).toHaveAttribute(
|
||||
'href',
|
||||
'/settings/signup?name=basic',
|
||||
);
|
||||
expect(screen.getByRole('link', { name: 'Forgot password' })).toHaveAttribute(
|
||||
'href',
|
||||
'/settings/forgot-password?name=basic',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns from sign-up to the registered Settings signin route', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/settings/signup?name=basic']}>
|
||||
<BasicSignUpForm authenticatorName="basic" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Log in with an existing account' })).toHaveAttribute(
|
||||
'href',
|
||||
'/settings/signin',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns from forgot-password to the registered Settings signin route', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/settings/forgot-password?name=basic']}>
|
||||
<ForgotPasswordPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Back to login' })).toHaveAttribute('href', '/settings/signin');
|
||||
});
|
||||
|
||||
it('returns from reset-password to the registered Settings signin route', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/settings/reset-password?name=basic&resetToken=token']}>
|
||||
<ResetPasswordPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Go to login' })).toHaveAttribute('href', '/settings/signin');
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,12 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import { useRedirect, useSignIn } from '../hooks';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
const mockState = vi.hoisted(() => ({
|
||||
basename: undefined as string | undefined,
|
||||
settingsRouteName: 'admin.settings.',
|
||||
settingsRouteRoot: '/admin/settings/',
|
||||
publicPath: '/nocobase/v2/',
|
||||
signIn: vi.fn().mockResolvedValue(undefined) as ReturnType<typeof vi.fn>,
|
||||
request: vi.fn().mockResolvedValue({ data: {} }) as ReturnType<typeof vi.fn>,
|
||||
}));
|
||||
@@ -27,20 +31,36 @@ vi.mock('react-router-dom', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@nocobase/client-v2', () => ({
|
||||
useApp: () => ({
|
||||
router: { getBasename: () => mockState.basename },
|
||||
apiClient: {
|
||||
auth: { signIn: (...args: unknown[]) => mockState.signIn(...args) },
|
||||
request: (...args: unknown[]) => mockState.request(...args),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock('@nocobase/client-v2', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nocobase/client-v2')>();
|
||||
return {
|
||||
...actual,
|
||||
useApp: () => ({
|
||||
router: { getBasename: () => mockState.basename },
|
||||
pluginSettingsManager: {
|
||||
getRouteName: () => mockState.settingsRouteName,
|
||||
getRoutePath: () => mockState.settingsRouteRoot,
|
||||
},
|
||||
getPublicPath: () => mockState.publicPath,
|
||||
apiClient: {
|
||||
auth: { signIn: (...args: unknown[]) => mockState.signIn(...args) },
|
||||
request: (...args: unknown[]) => mockState.request(...args),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('plugin-auth client-v2 useRedirect', () => {
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
mockState.basename = undefined;
|
||||
mockState.settingsRouteName = 'admin.settings.';
|
||||
mockState.settingsRouteRoot = '/admin/settings/';
|
||||
mockState.publicPath = '/nocobase/v2/';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
|
||||
});
|
||||
|
||||
function wrap(initialEntries: string[]) {
|
||||
@@ -104,6 +124,83 @@ describe('plugin-auth client-v2 useRedirect', () => {
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith('/admin', { replace: true });
|
||||
});
|
||||
|
||||
it('should use document navigation for a standalone settings redirect', () => {
|
||||
mockState.basename = '/nocobase/v2';
|
||||
const replace = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { ...originalLocation, replace },
|
||||
});
|
||||
const { result } = renderHook(() => useRedirect('/admin'), {
|
||||
wrapper: wrap(['/signin?redirect=%2Fnocobase%2Fsettings%2Fworkflow%3Ftab%3Dlist%23recent']),
|
||||
});
|
||||
|
||||
result.current();
|
||||
|
||||
expect(replace).toHaveBeenCalledWith('/nocobase/settings/workflow?tab=list#recent');
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use the current Settings root when no redirect param is present', () => {
|
||||
mockState.basename = '/nocobase/settings/apps/test-app';
|
||||
mockState.publicPath = '/nocobase/';
|
||||
mockState.settingsRouteName = 'settings.';
|
||||
mockState.settingsRouteRoot = '/';
|
||||
const replace = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { ...originalLocation, replace },
|
||||
});
|
||||
const { result } = renderHook(() => useRedirect(), {
|
||||
wrapper: wrap(['/settings/signin']),
|
||||
});
|
||||
|
||||
result.current();
|
||||
|
||||
expect(replace).toHaveBeenCalledWith('/nocobase/settings/apps/test-app');
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not interpret an apps segment inside the main public path as a Settings sub-app scope', () => {
|
||||
mockState.basename = '/tenant/apps/root';
|
||||
mockState.publicPath = '/tenant/apps/root/';
|
||||
mockState.settingsRouteName = 'settings.';
|
||||
mockState.settingsRouteRoot = '/settings/';
|
||||
const replace = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { ...originalLocation, replace },
|
||||
});
|
||||
const { result } = renderHook(() => useRedirect(), {
|
||||
wrapper: wrap(['/settings/signin']),
|
||||
});
|
||||
|
||||
result.current();
|
||||
|
||||
expect(replace).toHaveBeenCalledWith('/tenant/apps/root/settings/');
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/nocobase/settings/apps/test-app/../../other-app/settings',
|
||||
'/nocobase/settings/apps/test-app/%2e%2e/%2E%2e/other-app/settings',
|
||||
])('should reject a settings document redirect that resolves outside the current sub-app: %s', (target) => {
|
||||
mockState.basename = '/nocobase/v2/apps/test-app';
|
||||
const replace = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { ...originalLocation, replace },
|
||||
});
|
||||
const { result } = renderHook(() => useRedirect('/admin'), {
|
||||
wrapper: wrap([`/signin?redirect=${encodeURIComponent(target)}`]),
|
||||
});
|
||||
|
||||
result.current();
|
||||
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
expect(navigateMock).toHaveBeenCalledWith('/admin', { replace: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin-auth client-v2 useSignIn', () => {
|
||||
@@ -133,6 +230,7 @@ describe('plugin-auth client-v2 useSignIn', () => {
|
||||
// Order matters: signIn → request → redirect/navigate.
|
||||
expect(mockState.signIn.mock.invocationCallOrder[0]).toBeLessThan(mockState.request.mock.invocationCallOrder[0]);
|
||||
expect(mockState.request.mock.invocationCallOrder[0]).toBeLessThan(navigateMock.mock.invocationCallOrder[0]);
|
||||
expect(navigateMock).toHaveBeenCalledWith('/', { replace: true });
|
||||
});
|
||||
|
||||
it('should swallow /auth:check rejection so redirect still runs', async () => {
|
||||
|
||||
@@ -12,12 +12,27 @@ import PluginAuthClientV2 from '../plugin';
|
||||
|
||||
describe('plugin-auth client-v2', () => {
|
||||
const originalLocation = globalThis.window.location;
|
||||
const originalModernClientPrefix = window.__nocobase_modern_client_prefix__;
|
||||
let debounceClock = Date.now();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => debounceClock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
debounceClock += 3001;
|
||||
vi.advanceTimersByTime(3001);
|
||||
Object.defineProperty(globalThis.window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
if (originalModernClientPrefix === undefined) {
|
||||
delete window.__nocobase_modern_client_prefix__;
|
||||
} else {
|
||||
window.__nocobase_modern_client_prefix__ = originalModernClientPrefix;
|
||||
}
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -104,6 +119,98 @@ describe('plugin-auth client-v2', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should use the standalone Settings signin document for a Settings runtime 401', async () => {
|
||||
const replace = vi.fn();
|
||||
Object.defineProperty(globalThis.window, 'location', {
|
||||
configurable: true,
|
||||
value: { ...originalLocation, replace },
|
||||
});
|
||||
window.__nocobase_modern_client_prefix__ = 'v';
|
||||
const app = createMockClient({
|
||||
publicPath: '/',
|
||||
plugins: [PluginAuthClientV2 as any],
|
||||
router: { type: 'memory', initialEntries: ['/settings/workflow?tab=list#recent'] },
|
||||
});
|
||||
app.pluginSettingsManager.addMenuItem({ key: 'security', title: 'Security' });
|
||||
await app.load();
|
||||
const getRoutePath = app.pluginSettingsManager.getRoutePath.bind(app.pluginSettingsManager);
|
||||
const getRouteName = app.pluginSettingsManager.getRouteName.bind(app.pluginSettingsManager);
|
||||
vi.spyOn(app.pluginSettingsManager, 'getRouteName').mockImplementation((name) => {
|
||||
return name === '' ? 'settings.' : getRouteName(name);
|
||||
});
|
||||
vi.spyOn(app.pluginSettingsManager, 'getRoutePath').mockImplementation((name) => {
|
||||
return name === '' ? '/settings/' : getRoutePath(name);
|
||||
});
|
||||
app.router.router = {
|
||||
basename: '/',
|
||||
navigate: vi.fn(),
|
||||
state: {
|
||||
location: {
|
||||
pathname: '/settings/workflow',
|
||||
search: '?tab=list',
|
||||
hash: '#recent',
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const error = {
|
||||
response: { status: 401, data: { errors: [{ code: 'EXPIRED_SESSION' }] } },
|
||||
config: {},
|
||||
} as any;
|
||||
|
||||
// @ts-ignore
|
||||
app.apiClient.axios.interceptors.response.handlers[0].rejected(error);
|
||||
|
||||
expect(replace).toHaveBeenCalledWith('/settings/signin?redirect=%2Fsettings%2Fworkflow%3Ftab%3Dlist%23recent');
|
||||
});
|
||||
|
||||
it('should keep a sub-app Settings runtime in its document scope after a 401', async () => {
|
||||
const replace = vi.fn();
|
||||
Object.defineProperty(globalThis.window, 'location', {
|
||||
configurable: true,
|
||||
value: { ...originalLocation, replace },
|
||||
});
|
||||
const app = createMockClient({
|
||||
publicPath: '/nocobase/',
|
||||
plugins: [PluginAuthClientV2 as any],
|
||||
router: { type: 'memory', initialEntries: ['/settings/workflow?tab=list#recent'] },
|
||||
});
|
||||
app.pluginSettingsManager.addMenuItem({ key: 'security', title: 'Security' });
|
||||
await app.load();
|
||||
const getRoutePath = app.pluginSettingsManager.getRoutePath.bind(app.pluginSettingsManager);
|
||||
const getRouteName = app.pluginSettingsManager.getRouteName.bind(app.pluginSettingsManager);
|
||||
vi.spyOn(app.pluginSettingsManager, 'getRouteName').mockImplementation((name) => {
|
||||
return name === '' ? 'settings.' : getRouteName(name);
|
||||
});
|
||||
vi.spyOn(app.pluginSettingsManager, 'getRoutePath').mockImplementation((name) => {
|
||||
return name === '' ? '/' : getRoutePath(name);
|
||||
});
|
||||
app.router.setBasename('/nocobase/settings/apps/demo/');
|
||||
app.router.router = {
|
||||
basename: '/nocobase/settings/apps/demo/',
|
||||
navigate: vi.fn(),
|
||||
state: {
|
||||
location: {
|
||||
pathname: '/nocobase/settings/apps/demo/workflow',
|
||||
search: '?tab=list',
|
||||
hash: '#recent',
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const error = {
|
||||
response: { status: 401, data: { errors: [{ code: 'EXPIRED_SESSION' }] } },
|
||||
config: {},
|
||||
} as any;
|
||||
|
||||
// @ts-ignore
|
||||
app.apiClient.axios.interceptors.response.handlers[0].rejected(error);
|
||||
|
||||
expect(replace).toHaveBeenCalledWith(
|
||||
'/nocobase/settings/apps/demo/signin?redirect=%2Fnocobase%2Fsettings%2Fapps%2Fdemo%2Fworkflow%3Ftab%3Dlist%23recent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not redirect skipped auth routes on runtime 401', async () => {
|
||||
const navigateSpy = vi.fn();
|
||||
const app = createMockClient({
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
type AuthRouteName = 'auth.signin' | 'auth.signup' | 'auth.forgotPassword' | 'auth.resetPassword';
|
||||
|
||||
const authRouteFallbacks: Record<AuthRouteName, string> = {
|
||||
'auth.signin': '/signin',
|
||||
'auth.signup': '/signup',
|
||||
'auth.forgotPassword': '/forgot-password',
|
||||
'auth.resetPassword': '/reset-password',
|
||||
};
|
||||
|
||||
type PluginSettingsRouteApplication = {
|
||||
pluginSettingsManager: {
|
||||
getRouteName: (name: string) => string;
|
||||
getRoutePath: (name: string) => string;
|
||||
};
|
||||
};
|
||||
|
||||
type AuthRouteApplication = PluginSettingsRouteApplication & {
|
||||
router: {
|
||||
get: (name: string) => { path?: string } | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export function getAuthRoutePath(app: AuthRouteApplication, name: AuthRouteName) {
|
||||
const routePath = app.router.get(name)?.path;
|
||||
return typeof routePath === 'string' ? routePath : authRouteFallbacks[name];
|
||||
}
|
||||
|
||||
export function isStandaloneSettingsApplication(app: PluginSettingsRouteApplication) {
|
||||
return app.pluginSettingsManager.getRouteName('') === 'settings.';
|
||||
}
|
||||
|
||||
export function getDefaultAuthRedirectPath(app: PluginSettingsRouteApplication) {
|
||||
return isStandaloneSettingsApplication(app) ? app.pluginSettingsManager.getRoutePath('') : '/';
|
||||
}
|
||||
+5
-1
@@ -265,6 +265,7 @@ function useNotificationChannels(enabled: boolean) {
|
||||
|
||||
function ForgotPasswordTab() {
|
||||
const { t } = useAuthTranslation();
|
||||
const ctx = useFlowContext();
|
||||
const form = Form.useFormInstance();
|
||||
const enableResetPassword: boolean = Form.useWatch(['options', 'public', 'enableResetPassword'], form);
|
||||
const emailContentType: 'html' | 'text' = Form.useWatch(['options', 'emailContentType'], form) || 'html';
|
||||
@@ -304,7 +305,10 @@ function ForgotPasswordTab() {
|
||||
notFoundContent={
|
||||
<span>
|
||||
{t('No notification channels found. Please ')}
|
||||
<Link to="/admin/settings/notification-manager/channels">{t('add one first')}</Link>.
|
||||
<Link to={ctx.app.pluginSettingsManager.getRoutePath('notification-manager.channels')}>
|
||||
{t('add one first')}
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -8,13 +8,16 @@
|
||||
*/
|
||||
|
||||
import { Alert, Button, Form, Input } from 'antd';
|
||||
import { useApp } from '@nocobase/client-v2';
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { type Authenticator } from '../authenticator';
|
||||
import { useAuthTranslation } from '../locale';
|
||||
import { useSignIn } from '../hooks';
|
||||
import { getAuthRoutePath } from '../authRoutePaths';
|
||||
|
||||
export default function BasicSignInForm({ authenticator }: { authenticator: Authenticator }) {
|
||||
const app = useApp();
|
||||
const { t } = useAuthTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
@@ -22,6 +25,8 @@ export default function BasicSignInForm({ authenticator }: { authenticator: Auth
|
||||
const signIn = useSignIn(authenticator.name);
|
||||
const allowSignUp = !!authenticator?.options?.allowSignUp;
|
||||
const showForgotPassword = !!authenticator?.options?.enableResetPassword;
|
||||
const signupPath = getAuthRoutePath(app, 'auth.signup');
|
||||
const forgotPasswordPath = getAuthRoutePath(app, 'auth.forgotPassword');
|
||||
|
||||
return (
|
||||
<Form
|
||||
@@ -72,9 +77,9 @@ export default function BasicSignInForm({ authenticator }: { authenticator: Auth
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
|
||||
{allowSignUp ? <Link to={`/signup?name=${authenticator.name}`}>{t('Create an account')}</Link> : null}
|
||||
{allowSignUp ? <Link to={`${signupPath}?name=${authenticator.name}`}>{t('Create an account')}</Link> : null}
|
||||
{showForgotPassword ? (
|
||||
<Link to={`/forgot-password?name=${authenticator.name}`}>{t('Forgot password')}</Link>
|
||||
<Link to={`${forgotPasswordPath}?name=${authenticator.name}`}>{t('Forgot password')}</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useApp } from '@nocobase/client-v2';
|
||||
import { useAuthTranslation } from '../locale';
|
||||
import { useAuthenticator } from '../authenticator';
|
||||
import { getAuthRoutePath } from '../authRoutePaths';
|
||||
|
||||
type SignUpFormProps = {
|
||||
authenticatorName: string;
|
||||
@@ -78,6 +79,7 @@ export default function BasicSignUpForm({ authenticatorName }: SignUpFormProps)
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const signinPath = getAuthRoutePath(app, 'auth.signin');
|
||||
|
||||
const fields = useMemo(() => {
|
||||
return (authenticator?.options?.signupForm || []).filter((item: any) => item?.show);
|
||||
@@ -98,7 +100,7 @@ export default function BasicSignUpForm({ authenticatorName }: SignUpFormProps)
|
||||
await app.apiClient.auth.signUp(values, authenticatorName);
|
||||
message.success(t('Sign up successfully, and automatically jump to the sign in page'));
|
||||
window.setTimeout(() => {
|
||||
navigate('/signin', { replace: true });
|
||||
navigate(signinPath, { replace: true });
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
setErrorMessage(error?.response?.data?.errors?.[0]?.message || error?.message || String(error));
|
||||
@@ -139,7 +141,7 @@ export default function BasicSignUpForm({ authenticatorName }: SignUpFormProps)
|
||||
{t('Sign up')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Link to="/signin">{t('Log in with an existing account')}</Link>
|
||||
<Link to={signinPath}>{t('Log in with an existing account')}</Link>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,48 @@
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useApp } from '@nocobase/client-v2';
|
||||
import { normalizeV2RedirectPath, useApp } from '@nocobase/client-v2';
|
||||
import { getDefaultAuthRedirectPath, isStandaloneSettingsApplication } from './authRoutePaths';
|
||||
|
||||
function normalizePathname(pathname: string) {
|
||||
const value = `/${String(pathname || '/').trim()}`.replace(/\/{2,}/g, '/');
|
||||
const normalized = new URL(value, window.location.origin).pathname;
|
||||
return normalized === '/' ? normalized : normalized.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getSettingsScope(publicPath: string, pathname?: string) {
|
||||
const root = normalizePathname(publicPath).replace(/\/+$/, '');
|
||||
const path = normalizePathname(pathname || '/');
|
||||
const relativePath = root && (path === root || path.startsWith(`${root}/`)) ? path.slice(root.length) || '/' : path;
|
||||
return /^\/settings(\/(?:apps|_app)\/[^/]+)(?=\/|$)/.exec(relativePath)?.[1] || '';
|
||||
}
|
||||
|
||||
function isStandaloneSettingsRedirect(
|
||||
app: {
|
||||
getPublicPath: () => string;
|
||||
pluginSettingsManager: { getRouteName: (name: string) => string; getRoutePath: (name: string) => string };
|
||||
router: { getBasename?: () => string | undefined };
|
||||
},
|
||||
target: string,
|
||||
) {
|
||||
if (!target.startsWith('/') || target.startsWith('//') || target.startsWith('/\\')) {
|
||||
return false;
|
||||
}
|
||||
const basename = app.router.getBasename?.();
|
||||
const publicPath = normalizePathname(app.getPublicPath());
|
||||
const appScope = getSettingsScope(publicPath, basename);
|
||||
const publicPathSegments = publicPath.split('/');
|
||||
if (!isStandaloneSettingsApplication(app)) {
|
||||
publicPathSegments.pop();
|
||||
}
|
||||
const rootPublicPath = normalizePathname(publicPathSegments.join('/') || '/').replace(/\/+$/, '');
|
||||
const settingsBasePath = appScope
|
||||
? `${rootPublicPath}/settings${appScope}`
|
||||
: `${rootPublicPath}/settings` || '/settings';
|
||||
const targetPathname = normalizePathname(target.split(/[?#]/)[0]);
|
||||
|
||||
return targetPathname === settingsBasePath || targetPathname.startsWith(`${settingsBasePath}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `?redirect=` 上带 modern client basename 的目标(例如 `/nocobase/v/admin`)规约成
|
||||
@@ -32,17 +73,25 @@ function stripV2Basename(target: string, basename?: string): string {
|
||||
return target;
|
||||
}
|
||||
|
||||
export function useRedirect(next = '/admin') {
|
||||
export function useRedirect(next?: string) {
|
||||
const app = useApp();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
return useCallback(() => {
|
||||
const redirect = searchParams.get('redirect');
|
||||
const target = redirect || next;
|
||||
const fallbackPath = next || getDefaultAuthRedirectPath(app);
|
||||
const target =
|
||||
redirect || (next === undefined && isStandaloneSettingsApplication(app))
|
||||
? normalizeV2RedirectPath(app, redirect, fallbackPath)
|
||||
: fallbackPath;
|
||||
if (isStandaloneSettingsRedirect(app, target)) {
|
||||
window.location.replace(target);
|
||||
return;
|
||||
}
|
||||
const basename = app.router.getBasename?.();
|
||||
navigate(stripV2Basename(target, basename), { replace: true });
|
||||
}, [app.router, navigate, next, searchParams]);
|
||||
}, [app, navigate, next, searchParams]);
|
||||
}
|
||||
|
||||
export function useDocumentTitle(title: string) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useApp } from '@nocobase/client-v2';
|
||||
import { useAuthenticator } from '../authenticator';
|
||||
import { useAuthTranslation } from '../locale';
|
||||
import { useDocumentTitle } from '../hooks';
|
||||
import { getAuthRoutePath } from '../authRoutePaths';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const app = useApp();
|
||||
@@ -24,11 +25,12 @@ export default function ForgotPasswordPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const signinPath = getAuthRoutePath(app, 'auth.signin');
|
||||
|
||||
useDocumentTitle(t('Reset password'));
|
||||
|
||||
if (!authenticator?.options?.enableResetPassword) {
|
||||
return <Navigate to="/signin" replace />;
|
||||
return <Navigate to={signinPath} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -65,7 +67,7 @@ export default function ForgotPasswordPage() {
|
||||
{t('Send reset email')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Link to="/signin">{t('Back to login')}</Link>
|
||||
<Link to={signinPath}>{t('Back to login')}</Link>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useApp } from '@nocobase/client-v2';
|
||||
import { useAuthenticator } from '../authenticator';
|
||||
import { useAuthTranslation } from '../locale';
|
||||
import { useDocumentTitle } from '../hooks';
|
||||
import { getAuthRoutePath } from '../authRoutePaths';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const app = useApp();
|
||||
@@ -28,6 +29,7 @@ export default function ResetPasswordPage() {
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const signinPath = getAuthRoutePath(app, 'auth.signin');
|
||||
|
||||
useDocumentTitle(t('Reset password'));
|
||||
|
||||
@@ -61,7 +63,7 @@ export default function ResetPasswordPage() {
|
||||
}, [app, resetToken]);
|
||||
|
||||
if (!authenticator?.options?.enableResetPassword) {
|
||||
return <Navigate to="/signin" replace />;
|
||||
return <Navigate to={signinPath} replace />;
|
||||
}
|
||||
|
||||
if (!checking && (!resetToken || expired)) {
|
||||
@@ -70,7 +72,7 @@ export default function ResetPasswordPage() {
|
||||
status="403"
|
||||
title={t('Reset link has expired')}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate('/signin')}>
|
||||
<Button type="primary" onClick={() => navigate(signinPath)}>
|
||||
{t('Go to login')}
|
||||
</Button>
|
||||
}
|
||||
@@ -89,7 +91,7 @@ export default function ResetPasswordPage() {
|
||||
await app.apiClient.auth.resetPassword({ ...values, resetToken });
|
||||
message.success(t('Password reset successful'));
|
||||
window.setTimeout(() => {
|
||||
navigate('/signin', { replace: true });
|
||||
navigate(signinPath, { replace: true });
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
setErrorMessage(error?.response?.data?.errors?.[0]?.message || error?.message || String(error));
|
||||
@@ -129,7 +131,7 @@ export default function ResetPasswordPage() {
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Link to="/signin">{t('Go to login')}</Link>
|
||||
<Link to={signinPath}>{t('Go to login')}</Link>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { AuthenticatorsContext, type Authenticator } from '../authenticator';
|
||||
import { useDocumentTitle } from '../hooks';
|
||||
import { useAuthTranslation, useT } from '../locale';
|
||||
import PluginAuthClientV2, { type AuthOptions } from '../plugin';
|
||||
import { getDefaultAuthRedirectPath } from '../authRoutePaths';
|
||||
|
||||
type LoaderMap<L> = Record<string, L>;
|
||||
|
||||
@@ -64,7 +65,7 @@ export default function SignInPage() {
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const redirect = params.get('redirect');
|
||||
const normalized = normalizeV2RedirectPath(app, redirect);
|
||||
const normalized = normalizeV2RedirectPath(app, redirect, getDefaultAuthRedirectPath(app));
|
||||
if (redirect === normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,17 @@
|
||||
|
||||
import { Registry } from '@nocobase/utils/client';
|
||||
import type { ComponentType } from 'react';
|
||||
import { getCurrentV2RedirectPath, Plugin, UserCenterSelectItemModel, languageCodes } from '@nocobase/client-v2';
|
||||
import {
|
||||
getCurrentV2RedirectPath,
|
||||
languageCodes,
|
||||
Plugin,
|
||||
redirectToV2Signin,
|
||||
UserCenterSelectItemModel,
|
||||
} from '@nocobase/client-v2';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { presetAuthType } from '../preset';
|
||||
import type { Authenticator as AuthenticatorType } from './authenticator';
|
||||
import { isStandaloneSettingsApplication } from './authRoutePaths';
|
||||
import AuthProvider from './providers/AuthProvider';
|
||||
import { NAMESPACE } from './locale';
|
||||
|
||||
@@ -210,8 +217,12 @@ export class PluginAuthClientV2 extends Plugin {
|
||||
const redirectPath = getCurrentV2RedirectPath(this.app, locationLike);
|
||||
debouncedRedirect(() => {
|
||||
this.app.apiClient.auth.setToken('');
|
||||
// 用 react-router navigate (虚拟跳转)而不是 location.replace, 避免覆盖同时段其它响应拦截器触发的 window.location.href 整页跳转 (例如 2FA 接收到服务端 302 时)。
|
||||
this.app.router.navigate(`/signin?redirect=${encodeURIComponent(redirectPath)}`, { replace: true });
|
||||
if (isStandaloneSettingsApplication(this.app)) {
|
||||
redirectToV2Signin(this.app, redirectPath);
|
||||
} else {
|
||||
// 用 react-router navigate (虚拟跳转)而不是 location.replace, 避免覆盖同时段其它响应拦截器触发的 window.location.href 整页跳转 (例如 2FA 接收到服务端 302 时)。
|
||||
this.app.router.navigate(`/signin?redirect=${encodeURIComponent(redirectPath)}`, { replace: true });
|
||||
}
|
||||
});
|
||||
return new Promise<never>(() => undefined);
|
||||
}
|
||||
|
||||
+22
-1
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildRedirectPath } from '../buildRedirectPath';
|
||||
import { buildRedirectPath, resolveSigninPrefix } from '../buildRedirectPath';
|
||||
|
||||
describe('buildRedirectPath', () => {
|
||||
describe('main app', () => {
|
||||
@@ -123,3 +123,24 @@ describe('buildRedirectPath', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSigninPrefix', () => {
|
||||
it.each([
|
||||
['/nocobase/settings/workflow', '', '/nocobase/settings'],
|
||||
['/nocobase/settings/apps/sub/workflow', '/apps/sub', '/nocobase/settings/apps/sub'],
|
||||
['/nocobase/settings/_app/sub/workflow', '/apps/sub', '/nocobase/settings/_app/sub'],
|
||||
['/nocobase/settings/workflow?tab=list#recent', '', '/nocobase/settings'],
|
||||
])('returns the current Settings signin prefix for %s', (redirect, subAppSegment, expected) => {
|
||||
expect(resolveSigninPrefix({ appPublicPath: '/nocobase/', redirect, subAppSegment })).toBe(expected);
|
||||
});
|
||||
|
||||
it('recognizes a root-mounted Settings redirect', () => {
|
||||
expect(resolveSigninPrefix({ appPublicPath: '/', redirect: '/settings/workflow' })).toBe('/settings');
|
||||
});
|
||||
|
||||
it('does not confuse a sibling path with the Settings runtime', () => {
|
||||
expect(resolveSigninPrefix({ appPublicPath: '/nocobase/', redirect: '/nocobase/settings-preview' })).toBe(
|
||||
'/nocobase',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user