diff --git a/.gitignore b/.gitignore index e98b88ab677..b30b74f265b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docker/nocobase/docker-entrypoint.sh b/docker/nocobase/docker-entrypoint.sh index 8f5650c0d48..f93e549abe3 100755 --- a/docker/nocobase/docker-entrypoint.sh +++ b/docker/nocobase/docker-entrypoint.sh @@ -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 diff --git a/lerna.json b/lerna.json index a3c60e5759e..6043de8f4b6 100644 --- a/lerna.json +++ b/lerna.json @@ -2,9 +2,7 @@ "version": "2.2.0-alpha.11", "npmClient": "yarn", "useWorkspaces": true, - "npmClientArgs": [ - "--ignore-engines" - ], + "npmClientArgs": ["--ignore-engines"], "command": { "version": { "forcePublish": true, diff --git a/packages/core/app/__tests__/settingsDevProxy.test.ts b/packages/core/app/__tests__/settingsDevProxy.test.ts new file mode 100644 index 00000000000..7694fa869ce --- /dev/null +++ b/packages/core/app/__tests__/settingsDevProxy.test.ts @@ -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', + ); + }); +}); diff --git a/packages/core/app/__tests__/settingsPluginImports.test.ts b/packages/core/app/__tests__/settingsPluginImports.test.ts new file mode 100644 index 00000000000..e15ddcfab02 --- /dev/null +++ b/packages/core/app/__tests__/settingsPluginImports.test.ts @@ -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'"); + }); +}); diff --git a/packages/core/app/client-settings/SETTINGS_SIGNIN_IMPLEMENTATION_PLAN.md b/packages/core/app/client-settings/SETTINGS_SIGNIN_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000000..220fb4ceeec --- /dev/null +++ b/packages/core/app/client-settings/SETTINGS_SIGNIN_IMPLEMENTATION_PLAN.md @@ -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= +``` + +普通 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 或数据迁移。 diff --git a/packages/core/app/client-settings/generatePluginImports.ts b/packages/core/app/client-settings/generatePluginImports.ts new file mode 100644 index 00000000000..21afeb35582 --- /dev/null +++ b/packages/core/app/client-settings/generatePluginImports.ts @@ -0,0 +1,36 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import 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(); +} diff --git a/packages/core/app/client-settings/index.html b/packages/core/app/client-settings/index.html new file mode 100644 index 00000000000..3e1c97e931c --- /dev/null +++ b/packages/core/app/client-settings/index.html @@ -0,0 +1,11 @@ + + + + + + Loading... + + +
+ + diff --git a/packages/core/app/client-settings/rsbuild.config.ts b/packages/core/app/client-settings/rsbuild.config.ts new file mode 100644 index 00000000000..6806a73ba96 --- /dev/null +++ b/packages/core/app/client-settings/rsbuild.config.ts @@ -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'; + }, + }, + }; +}); diff --git a/packages/core/app/client-settings/src/SettingsPresetPlugin.ts b/packages/core/app/client-settings/src/SettingsPresetPlugin.ts new file mode 100644 index 00000000000..6a537d810b9 --- /dev/null +++ b/packages/core/app/client-settings/src/SettingsPresetPlugin.ts @@ -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 { + 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' }); + } +} diff --git a/packages/core/app/client-settings/src/__tests__/runtimePublicPath.test.ts b/packages/core/app/client-settings/src/__tests__/runtimePublicPath.test.ts new file mode 100644 index 00000000000..c52eda0bd4a --- /dev/null +++ b/packages/core/app/client-settings/src/__tests__/runtimePublicPath.test.ts @@ -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/', + ); + }); +}); diff --git a/packages/core/app/client-settings/src/__tests__/runtimeScope.test.ts b/packages/core/app/client-settings/src/__tests__/runtimeScope.test.ts new file mode 100644 index 00000000000..d3b7cdd21ad --- /dev/null +++ b/packages/core/app/client-settings/src/__tests__/runtimeScope.test.ts @@ -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/', + }); + }); +}); diff --git a/packages/core/app/client-settings/src/env.d.ts b/packages/core/app/client-settings/src/env.d.ts new file mode 100644 index 00000000000..a4805fa2a30 --- /dev/null +++ b/packages/core/app/client-settings/src/env.d.ts @@ -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; +} diff --git a/packages/core/app/client-settings/src/main.tsx b/packages/core/app/client-settings/src/main.tsx new file mode 100644 index 00000000000..6f47066747c --- /dev/null +++ b/packages/core/app/client-settings/src/main.tsx @@ -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'); diff --git a/packages/core/app/client-settings/src/runtimePublicPath.ts b/packages/core/app/client-settings/src/runtimePublicPath.ts new file mode 100644 index 00000000000..e467298f31c --- /dev/null +++ b/packages/core/app/client-settings/src/runtimePublicPath.ts @@ -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(/^\/\//, '/'); +} diff --git a/packages/core/app/client-settings/src/runtimeScope.ts b/packages/core/app/client-settings/src/runtimeScope.ts new file mode 100644 index 00000000000..552845bac27 --- /dev/null +++ b/packages/core/app/client-settings/src/runtimeScope.ts @@ -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, + }; +} diff --git a/packages/core/app/client-settings/tsconfig.json b/packages/core/app/client-settings/tsconfig.json new file mode 100644 index 00000000000..782e80279cb --- /dev/null +++ b/packages/core/app/client-settings/tsconfig.json @@ -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"] +} diff --git a/packages/core/app/client-v2/public/browser-checker.js b/packages/core/app/client-v2/public/browser-checker.js index baf44310dee..88f716e3fac 100644 --- a/packages/core/app/client-v2/public/browser-checker.js +++ b/packages/core/app/client-v2/public/browser-checker.js @@ -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)) { diff --git a/packages/core/app/client-v2/rsbuild.config.ts b/packages/core/app/client-v2/rsbuild.config.ts index 04f678ec1ee..eb89a9788c6 100644 --- a/packages/core/app/client-v2/rsbuild.config.ts +++ b/packages/core/app/client-v2/rsbuild.config.ts @@ -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; diff --git a/packages/core/app/client/rsbuild.config.ts b/packages/core/app/client/rsbuild.config.ts index 9968285cabc..e32c38b45c1 100644 --- a/packages/core/app/client/rsbuild.config.ts +++ b/packages/core/app/client/rsbuild.config.ts @@ -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`, diff --git a/packages/core/app/settingsDevProxy.ts b/packages/core/app/settingsDevProxy.ts new file mode 100644 index 00000000000..f61e7252217 --- /dev/null +++ b/packages/core/app/settingsDevProxy.ts @@ -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), + }; +} diff --git a/packages/core/build/src/__tests__/clientSettingsBuildStage.test.ts b/packages/core/build/src/__tests__/clientSettingsBuildStage.test.ts new file mode 100644 index 00000000000..051d0b878a7 --- /dev/null +++ b/packages/core/build/src/__tests__/clientSettingsBuildStage.test.ts @@ -0,0 +1,28 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +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__']"); + }); +}); diff --git a/packages/core/build/src/build.ts b/packages/core/build/src/build.ts index 2c20d02ea72..76eb66de875 100755 --- a/packages/core/build/src/build.ts +++ b/packages/core/build/src/build.ts @@ -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})` : '' + }`, ), ); } diff --git a/packages/core/cli-v1/nocobase.conf.tpl b/packages/core/cli-v1/nocobase.conf.tpl index 78b28bd7c03..92ad3cb554c 100644 --- a/packages/core/cli-v1/nocobase.conf.tpl +++ b/packages/core/cli-v1/nocobase.conf.tpl @@ -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; diff --git a/packages/core/cli-v1/src/__tests__/create-nginx-conf.test.js b/packages/core/cli-v1/src/__tests__/create-nginx-conf.test.js new file mode 100644 index 00000000000..978b402c8a9 --- /dev/null +++ b/packages/core/cli-v1/src/__tests__/create-nginx-conf.test.js @@ -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;'); + }, + ); +}); diff --git a/packages/core/cli-v1/src/__tests__/dev-command.test.js b/packages/core/cli-v1/src/__tests__/dev-command.test.js index d5b6b50132b..cd1cb607dc8 100644 --- a/packages/core/cli-v1/src/__tests__/dev-command.test.js +++ b/packages/core/cli-v1/src/__tests__/dev-command.test.js @@ -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', + }, + }, + }); + }); }); diff --git a/packages/core/cli-v1/src/__tests__/util.test.js b/packages/core/cli-v1/src/__tests__/util.test.js index 111d68db364..42f58d65434 100644 --- a/packages/core/cli-v1/src/__tests__/util.test.js +++ b/packages/core/cli-v1/src/__tests__/util.test.js @@ -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' }); }); diff --git a/packages/core/cli-v1/src/commands/create-nginx-conf.js b/packages/core/cli-v1/src/commands/create-nginx-conf.js index 653351e802f..8cf96a5b231 100644 --- a/packages/core/cli-v1/src/commands/create-nginx-conf.js +++ b/packages/core/cli-v1/src/commands/create-nginx-conf.js @@ -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'); diff --git a/packages/core/cli-v1/src/commands/dev.js b/packages/core/cli-v1/src/commands/dev.js index 99cad0d522e..498ec809c32 100644 --- a/packages/core/cli-v1/src/commands/dev.js +++ b/packages/core/cli-v1/src/commands/dev.js @@ -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, }; diff --git a/packages/core/cli-v1/src/util.js b/packages/core/cli-v1/src/util.js index 8ccbc0f5a8e..034bdde6684 100644 --- a/packages/core/cli-v1/src/util.js +++ b/packages/core/cli-v1/src/util.js @@ -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; diff --git a/packages/core/cli/src/__tests__/app-management-commands.test.ts b/packages/core/cli/src/__tests__/app-management-commands.test.ts index 69e2b01f712..4f14cd44633 100644 --- a/packages/core/cli/src/__tests__/app-management-commands.test.ts +++ b/packages/core/cli/src/__tests__/app-management-commands.test.ts @@ -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"...']]); diff --git a/packages/core/cli/src/__tests__/env-proxy.test.ts b/packages/core/cli/src/__tests__/env-proxy.test.ts index 60a1baf307d..454715aab9d 100644 --- a/packages/core/cli/src/__tests__/env-proxy.test.ts +++ b/packages/core/cli/src/__tests__/env-proxy.test.ts @@ -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( '', ].join(''), ); + await writeFile( + path.join(versionRoot, 'settings', 'index.html'), + [ + '', + '', + ``, + '', + '', + '', + '', + ].join(''), + ); - return ({ + return { kind: 'local', envName: 'demo', source: 'npm', @@ -137,7 +160,7 @@ async function createLocalRuntime( version, }, }, - } as unknown) as Extract; + } as unknown as Extract; } 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/(?[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'), '', @@ -318,6 +402,10 @@ test('buildManualEnvProxyNginxBundle reads versioned index files from distRootPa path.join(versionRoot, 'v', 'index.html'), '', ); + await writeFile( + path.join(versionRoot, 'settings', 'index.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'), '', @@ -604,6 +726,10 @@ test('buildManualEnvProxyCaddyBundle reads versioned index files from distRootPa path.join(versionRoot, 'v', 'index.html'), '', ); + await writeFile( + path.join(versionRoot, 'settings', 'index.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'); diff --git a/packages/core/cli/src/__tests__/init-install-argv.test.ts b/packages/core/cli/src/__tests__/init-install-argv.test.ts index e3fecd13306..8039ddb3ab7 100644 --- a/packages/core/cli/src/__tests__/init-install-argv.test.ts +++ b/packages/core/cli/src/__tests__/init-install-argv.test.ts @@ -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', () => { diff --git a/packages/core/cli/src/__tests__/init.test.ts b/packages/core/cli/src/__tests__/init.test.ts index 9bff43cecbb..5b9c8611fcf 100644 --- a/packages/core/cli/src/__tests__/init.test.ts +++ b/packages/core/cli/src/__tests__/init.test.ts @@ -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(); + return { + ...actual, + ensureManagedEnvFileDefaults: mocks.ensureManagedEnvFileDefaults, + }; +}); + vi.mock('../lib/skills-manager.ts', async (importOriginal) => { const actual = await importOriginal(); 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; + const finalCatalog = webUiOptions?.stages[6]?.catalog as Record; 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, - ) => Promise>; - } - ).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('../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('../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; diff --git a/packages/core/cli/src/__tests__/install-resume.test.ts b/packages/core/cli/src/__tests__/install-resume.test.ts index 39c9797a50e..57b1f86adbe 100644 --- a/packages/core/cli/src/__tests__/install-resume.test.ts +++ b/packages/core/cli/src/__tests__/install-resume.test.ts @@ -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 () => { diff --git a/packages/core/cli/src/__tests__/managed-env-file.test.ts b/packages/core/cli/src/__tests__/managed-env-file.test.ts new file mode 100644 index 00000000000..43a06a037eb --- /dev/null +++ b/packages/core/cli/src/__tests__/managed-env-file.test.ts @@ -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'), + ); +}); diff --git a/packages/core/cli/src/__tests__/managed-init-env.test.ts b/packages/core/cli/src/__tests__/managed-init-env.test.ts index b4f3edbc7b7..ba824de7e36 100644 --- a/packages/core/cli/src/__tests__/managed-init-env.test.ts +++ b/packages/core/cli/src/__tests__/managed-init-env.test.ts @@ -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 }, diff --git a/packages/core/cli/src/__tests__/portal-deploy.test.ts b/packages/core/cli/src/__tests__/portal-deploy.test.ts index c6683a33dfa..8d9c0eb49d2 100644 --- a/packages/core/cli/src/__tests__/portal-deploy.test.ts +++ b/packages/core/cli/src/__tests__/portal-deploy.test.ts @@ -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); } }, }); diff --git a/packages/core/cli/src/__tests__/portal-destroy-command.test.ts b/packages/core/cli/src/__tests__/portal-destroy-command.test.ts index 0f12225e6e0..38fac483f7a 100644 --- a/packages/core/cli/src/__tests__/portal-destroy-command.test.ts +++ b/packages/core/cli/src/__tests__/portal-destroy-command.test.ts @@ -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)'], ]); }); diff --git a/packages/core/cli/src/__tests__/portal-registry-sync-command.test.ts b/packages/core/cli/src/__tests__/portal-registry-sync-command.test.ts index f3f906fe61b..ac31659f88d 100644 --- a/packages/core/cli/src/__tests__/portal-registry-sync-command.test.ts +++ b/packages/core/cli/src/__tests__/portal-registry-sync-command.test.ts @@ -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(); +}); diff --git a/packages/core/cli/src/__tests__/portal-source.test.ts b/packages/core/cli/src/__tests__/portal-source.test.ts index 3d6ba9ce41e..cbd3f5d5e86 100644 --- a/packages/core/cli/src/__tests__/portal-source.test.ts +++ b/packages/core/cli/src/__tests__/portal-source.test.ts @@ -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', ); }); diff --git a/packages/core/cli/src/commands/init.ts b/packages/core/cli/src/commands/init.ts index 629b09c5c37..443f1f0f361 100644 --- a/packages/core/cli/src/commands/init.ts +++ b/packages/core/cli/src/commands/init.ts @@ -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): boolean { - return String(values.portalType ?? DEFAULT_INIT_PORTAL_TYPE).trim() === 'ai'; -} - function remoteConnectionOnly(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 = { + 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[] { @@ -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; diff --git a/packages/core/cli/src/commands/install.ts b/packages/core/cli/src/commands/install.ts index 5ca70b890c5..0d63ca00299 100644 --- a/packages/core/cli/src/commands/install.ts +++ b/packages/core/cli/src/commands/install.ts @@ -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; rootResults: Record; envAddResults: Record; + ensureEnvFileDefaults?: boolean; }): Promise { 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({ diff --git a/packages/core/cli/src/commands/portal/registry/sync.ts b/packages/core/cli/src/commands/portal/registry/sync.ts index 099e7951b1d..7fc913cb4c3 100644 --- a/packages/core/cli/src/commands/portal/registry/sync.ts +++ b/packages/core/cli/src/commands/portal/registry/sync.ts @@ -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, 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 { @@ -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( diff --git a/packages/core/cli/src/lib/env-proxy.ts b/packages/core/cli/src/lib/env-proxy.ts index 51de26536fe..71371feaba5 100644 --- a/packages/core/cli/src/lib/env-proxy.ts +++ b/packages/core/cli/src/lib/env-proxy.ts @@ -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 { +function buildNginxRuntimeConfig( + context: EnvProxyNginxRenderContext, + variant: 'v1' | 'v2' | 'settings', +): Record { 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 { +function buildCaddyRuntimeConfig( + context: EnvProxyCaddyRenderContext, + variant: 'v1' | 'v2' | 'settings', +): Record { 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, diff --git a/packages/core/cli/src/lib/managed-env-file.ts b/packages/core/cli/src/lib/managed-env-file.ts index 6ab7799f01a..4227f4c1c9f 100644 --- a/packages/core/cli/src/lib/managed-env-file.ts +++ b/packages/core/cli/src/lib/managed-env-file.ts @@ -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, +): 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, + defaults: Record = DEFAULT_MANAGED_ENV_FILE_VALUES, +): Promise { + 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, ): Promise { diff --git a/packages/core/cli/src/lib/proxy-caddy.ts b/packages/core/cli/src/lib/proxy-caddy.ts index d1e9e67d69f..25bc66e922e 100644 --- a/packages/core/cli/src/lib/proxy-caddy.ts +++ b/packages/core/cli/src/lib/proxy-caddy.ts @@ -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'), ]); diff --git a/packages/core/cli/src/lib/proxy-nginx.ts b/packages/core/cli/src/lib/proxy-nginx.ts index 0891749b718..b7c4be941b7 100644 --- a/packages/core/cli/src/lib/proxy-nginx.ts +++ b/packages/core/cli/src/lib/proxy-nginx.ts @@ -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(), ]); diff --git a/packages/core/client-v2/src/__tests__/authRedirect.test.ts b/packages/core/client-v2/src/__tests__/authRedirect.test.ts index 5587e9dd033..bca83ce1690 100644 --- a/packages/core/client-v2/src/__tests__/authRedirect.test.ts +++ b/packages/core/client-v2/src/__tests__/authRedirect.test.ts @@ -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[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[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//)', () => { + 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/', diff --git a/packages/core/client-v2/src/__tests__/browserChecker.test.ts b/packages/core/client-v2/src/__tests__/browserChecker.test.ts index 152c8c22a3c..3dbf9f47a8b 100644 --- a/packages/core/client-v2/src/__tests__/browserChecker.test.ts +++ b/packages/core/client-v2/src/__tests__/browserChecker.test.ts @@ -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(); + }); + } }); diff --git a/packages/core/client-v2/src/__tests__/mockSettingsApplication.ts b/packages/core/client-v2/src/__tests__/mockSettingsApplication.ts new file mode 100644 index 00000000000..0eff7517610 --- /dev/null +++ b/packages/core/client-v2/src/__tests__/mockSettingsApplication.ts @@ -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); +} diff --git a/packages/core/client-v2/src/__tests__/nocobase-buildin-plugin-auth.test.tsx b/packages/core/client-v2/src/__tests__/nocobase-buildin-plugin-auth.test.tsx index 6731ef72fda..eb348934021 100644 --- a/packages/core/client-v2/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +++ b/packages/core/client-v2/src/__tests__/nocobase-buildin-plugin-auth.test.tsx @@ -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: () =>
portal landing
, + }); + } +} + 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 ', 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(); 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, { diff --git a/packages/core/client-v2/src/__tests__/plugin-manager.test.tsx b/packages/core/client-v2/src/__tests__/plugin-manager.test.tsx index 08370b72dba..1bdc0f4325b 100644 --- a/packages/core/client-v2/src/__tests__/plugin-manager.test.tsx +++ b/packages/core/client-v2/src/__tests__/plugin-manager.test.tsx @@ -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; +type MockClientApplication = ReturnType; 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 = []) => { - 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, { diff --git a/packages/core/client-v2/src/__tests__/settings-application.test.ts b/packages/core/client-v2/src/__tests__/settings-application.test.ts new file mode 100644 index 00000000000..f2689bf70eb --- /dev/null +++ b/packages/core/client-v2/src/__tests__/settings-application.test.ts @@ -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); + }); +}); diff --git a/packages/core/client-v2/src/__tests__/settings-auth-routes.test.ts b/packages/core/client-v2/src/__tests__/settings-auth-routes.test.ts new file mode 100644 index 00000000000..4e03d5b6f6b --- /dev/null +++ b/packages/core/client-v2/src/__tests__/settings-auth-routes.test.ts @@ -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); + }); +}); diff --git a/packages/core/client-v2/src/__tests__/settings-center.test.tsx b/packages/core/client-v2/src/__tests__/settings-center.test.tsx index 0f11824d428..9c1cba6e80e 100644 --- a/packages/core/client-v2/src/__tests__/settings-center.test.tsx +++ b/packages/core/client-v2/src/__tests__/settings-center.test.tsx @@ -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; +type MockClientApplication = ReturnType; 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; diff --git a/packages/core/client-v2/src/__tests__/settings-layout-root.test.tsx b/packages/core/client-v2/src/__tests__/settings-layout-root.test.tsx new file mode 100644 index 00000000000..abaa5e8c76f --- /dev/null +++ b/packages/core/client-v2/src/__tests__/settings-layout-root.test.tsx @@ -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: () =>
Standalone settings page
, + 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: () =>
Portal manager page
, + }); + } +} + +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: () =>
Portal manager page
, + }); + } +} + +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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/packages/core/client-v2/src/__tests__/settings-runtime-paths.test.ts b/packages/core/client-v2/src/__tests__/settings-runtime-paths.test.ts new file mode 100644 index 00000000000..d9a210d65e9 --- /dev/null +++ b/packages/core/client-v2/src/__tests__/settings-runtime-paths.test.ts @@ -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', + ); + }); +}); diff --git a/packages/core/client-v2/src/__tests__/settings-shell.test.tsx b/packages/core/client-v2/src/__tests__/settings-shell.test.tsx new file mode 100644 index 00000000000..594b14eca04 --- /dev/null +++ b/packages/core/client-v2/src/__tests__/settings-shell.test.tsx @@ -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: () =>
logo
, +})); + +vi.mock('../flow/admin-shell/admin-layout/HelpLite', () => ({ + HelpLite: () =>
help
, +})); + +vi.mock('@nocobase/flow-engine', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + FlowModelRenderer: ({ model }: { model: { uid: string } }) => ( +
{model.uid}
+ ), + }; +}); + +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( + + +
settings content
+
+
, + ); + + 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( + + + +
settings content
+
+
+
, + ); + + expect(screen.getByRole('banner')).toHaveStyle({ background: '#176CE1' }); + }); + + it('places the settings content and embed container side by side below the header', () => { + const { container } = render( + + +
settings content
+
+
, + ); + + const header = screen.getByRole('banner'); + const content = screen.getByRole('main'); + const embedContainer = container.querySelector('#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( + + +
authentication content
+
+
, + ); + + 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(); + }); +}); diff --git a/packages/core/client-v2/src/authRedirect.ts b/packages/core/client-v2/src/authRedirect.ts index b38d4f21072..15be999d93b 100644 --- a/packages/core/client-v2/src/authRedirect.ts +++ b/packages/core/client-v2/src/authRedirect.ts @@ -8,8 +8,19 @@ */ import type { BaseApplication } from './BaseApplication'; +import { + resolveSettingsAppScope, + resolveSettingsAppScopeWithinPublicPath, + resolveSettingsDocumentPath, + type SettingsAppScope, +} from './settings-app/settingsDocumentPath'; type AppLike = Pick, '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; } diff --git a/packages/core/client-v2/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx b/packages/core/client-v2/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx index 78cd94e672c..499194f97d5 100644 --- a/packages/core/client-v2/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +++ b/packages/core/client-v2/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx @@ -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 ? ( - - ) : ( - - {t(result?.data?.data?.title)} - - ); - - return ( -
{result?.loading ? null : logo}
- ); -}); +import { NocoBaseLogo } from './NocoBaseLogo'; const resetStyle = css` .ant-layout-sider-children { diff --git a/packages/core/client-v2/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx b/packages/core/client-v2/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx new file mode 100644 index 00000000000..8ab60d33b03 --- /dev/null +++ b/packages/core/client-v2/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx @@ -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( + () => ({ color: customToken.colorTextHeaderMenu, fontSize: customToken.fontSizeHeading3 }), + [customToken.colorTextHeaderMenu, customToken.fontSizeHeading3], + ); + + const logo = logoUrl ? ( + {title} + ) : ( + + {title} + + ); + + return
{result?.loading ? null : logo}
; +}); + +NocoBaseLogo.displayName = 'NocoBaseLogo'; diff --git a/packages/core/client-v2/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx b/packages/core/client-v2/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx index d05283ee912..674ae7a4ff4 100644 --- a/packages/core/client-v2/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +++ b/packages/core/client-v2/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx @@ -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[]; - - {userCenterAction ? ( - { - console.error('[NocoBase] Topbar action render failed.', error); - }} - > - - - ) : null} +
+ {pluginSettingsAction ? ( + + { + console.error('[NocoBase] Topbar action render failed.', error); + }} + > + + + + ) : null} + + {userCenterAction ? ( + { + console.error('[NocoBase] Topbar action render failed.', error); + }} + > + + + ) : null} +
); }); diff --git a/packages/core/client-v2/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx b/packages/core/client-v2/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx index a18c54dc79d..c6dd041ae39 100644 --- a/packages/core/client-v2/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +++ b/packages/core/client-v2/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx @@ -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( + , + ); + + 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(() => {}); diff --git a/packages/core/client-v2/src/flow/models/topbar/TopbarActionModel.tsx b/packages/core/client-v2/src/flow/models/topbar/TopbarActionModel.tsx index 93015310d7e..60a5f98bd20 100644 --- a/packages/core/client-v2/src/flow/models/topbar/TopbarActionModel.tsx +++ b/packages/core/client-v2/src/flow/models/topbar/TopbarActionModel.tsx @@ -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, - '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 (
- {props.title} - - ); - } - - if (isAdminRuntimePath(currentRoutePath)) { - return {props.title}; - } return ( - + {props.title} ); @@ -238,66 +108,68 @@ export function getTopbarPluginSettingsItems(options: { }): NonNullable { 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(); 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 ? ( - - ) : ( - - ), - }; - }); - - const items: NonNullable = []; - - if (canManagePlugins && pluginManagerSetting) { - items.push({ - key: pluginManagerSetting.key, - icon: pluginManagerSetting.icon || , - label: ( - - ), + return { + key: item.key, + name: matchedSetting?.name, + path: matchedSetting?.path, + link: targetLink, + title: targetTitle, + icon: isPluginManager ? matchedSetting?.icon || : item.icon, + label: targetLink ? ( + + ) : ( + + ), + }; }); - } - if (canManagePlugins && orderedSettings.length) { + const primaryItems = buildMenuItems(primarySettings); + const normalItems = buildMenuItems(normalSettings); + const items: NonNullable = [...primaryItems]; + + if (primaryItems.length && normalItems.length) { items.push({ type: 'divider' }); } - items.push(...orderedSettings); + items.push(...normalItems); return items; } diff --git a/packages/core/client-v2/src/index.ts b/packages/core/client-v2/src/index.ts index 2736d808aeb..0fed9618409 100644 --- a/packages/core/client-v2/src/index.ts +++ b/packages/core/client-v2/src/index.ts @@ -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'; diff --git a/packages/core/client-v2/src/nocobase-buildin-plugin/currentUserAuthStatus.ts b/packages/core/client-v2/src/nocobase-buildin-plugin/currentUserAuthStatus.ts new file mode 100644 index 00000000000..39d62608712 --- /dev/null +++ b/packages/core/client-v2/src/nocobase-buildin-plugin/currentUserAuthStatus.ts @@ -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(); + +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); +} diff --git a/packages/core/client-v2/src/nocobase-buildin-plugin/index.tsx b/packages/core/client-v2/src/nocobase-buildin-plugin/index.tsx index 9fe4daec058..08d3c76e8d0 100644 --- a/packages/core/client-v2/src/nocobase-buildin-plugin/index.tsx +++ b/packages/core/client-v2/src/nocobase-buildin-plugin/index.tsx @@ -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, +) { + 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(); 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(); - const hasToken = !!app?.apiClient?.auth?.token; - const targetPath = getDefaultV2AdminRedirectPath(app); - - if (!hasToken) { - // 用 react-router 而非 location.replace, 避免覆盖同时段其它响应拦截器触发的 window.location.href (例如 2FA 接收到服务端 302 时设置的整页跳转)。 - return ; - } - - return ; -}; - /** * client-v2 使用的内建插件集合。 * @@ -346,56 +346,11 @@ export class NocoBaseBuildInPlugin extends Plugin { 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: , - }); - this.router.add('not-found', { path: '*', Component: AppNotFound, @@ -403,7 +358,7 @@ export class NocoBaseBuildInPlugin extends Plugin { 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 { } } +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 { + 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 }; diff --git a/packages/core/client-v2/src/settings-app/SettingsApplication.ts b/packages/core/client-v2/src/settings-app/SettingsApplication.ts new file mode 100644 index 00000000000..10092592ae8 --- /dev/null +++ b/packages/core/client-v2/src/settings-app/SettingsApplication.ts @@ -0,0 +1,28 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +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 { + return new SettingsRouterManager(options.router, this); + } + + protected createPluginSettingsManager(_options: ApplicationOptions): SettingsPluginSettingsManager { + return new SettingsPluginSettingsManager(this); + } +} diff --git a/packages/core/client-v2/src/settings-app/SettingsBuildInPlugin.ts b/packages/core/client-v2/src/settings-app/SettingsBuildInPlugin.ts new file mode 100644 index 00000000000..676537ddce7 --- /dev/null +++ b/packages/core/client-v2/src/settings-app/SettingsBuildInPlugin.ts @@ -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'; diff --git a/packages/core/client-v2/src/settings-app/SettingsDocumentRedirect.tsx b/packages/core/client-v2/src/settings-app/SettingsDocumentRedirect.tsx new file mode 100644 index 00000000000..2df01586cca --- /dev/null +++ b/packages/core/client-v2/src/settings-app/SettingsDocumentRedirect.tsx @@ -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'); +} diff --git a/packages/core/client-v2/src/settings-app/SettingsPluginSettingsManager.ts b/packages/core/client-v2/src/settings-app/SettingsPluginSettingsManager.ts new file mode 100644 index 00000000000..57aeb5cbeba --- /dev/null +++ b/packages/core/client-v2/src/settings-app/SettingsPluginSettingsManager.ts @@ -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 = BaseApplication, +> extends PluginSettingsManager { + 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}`; + } +} diff --git a/packages/core/client-v2/src/settings-app/SettingsRouterManager.ts b/packages/core/client-v2/src/settings-app/SettingsRouterManager.ts new file mode 100644 index 00000000000..cb8129ef68a --- /dev/null +++ b/packages/core/client-v2/src/settings-app/SettingsRouterManager.ts @@ -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 = BaseApplication, +> extends RouterManager { + 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, + ); + } +} diff --git a/packages/core/client-v2/src/settings-app/SettingsShell.tsx b/packages/core/client-v2/src/settings-app/SettingsShell.tsx new file mode 100644 index 00000000000..3d11feb1b47 --- /dev/null +++ b/packages/core/client-v2/src/settings-app/SettingsShell.tsx @@ -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(`topbar-action-${USER_CENTER_ACTION_ID}`) || + app.flowEngine.createModel({ + use: 'UserCenterTopbarActionModel', + uid: `topbar-action-${USER_CENTER_ACTION_ID}`, + }) + : null; + + return ( + + + +
+ +
+ + {userCenter ? : null} +
+
+
+
+ {children} +
+
+ + + ); +}; diff --git a/packages/core/client-v2/src/settings-app/runtimePaths.ts b/packages/core/client-v2/src/settings-app/runtimePaths.ts new file mode 100644 index 00000000000..c66ab40385d --- /dev/null +++ b/packages/core/client-v2/src/settings-app/runtimePaths.ts @@ -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, '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}`; +} diff --git a/packages/core/client-v2/src/settings-app/settingsDocumentPath.ts b/packages/core/client-v2/src/settings-app/settingsDocumentPath.ts new file mode 100644 index 00000000000..930d1327c26 --- /dev/null +++ b/packages/core/client-v2/src/settings-app/settingsDocumentPath.ts @@ -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}`); +} diff --git a/packages/core/client-v2/src/settings-center/AdminSettingsLayout.tsx b/packages/core/client-v2/src/settings-center/AdminSettingsLayout.tsx index 7c71435281a..0f270ba4118 100644 --- a/packages/core/client-v2/src/settings-center/AdminSettingsLayout.tsx +++ b/packages/core/client-v2/src/settings-center/AdminSettingsLayout.tsx @@ -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 || , - 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 ; diff --git a/packages/core/devtools/__tests__/umiConfig.test.js b/packages/core/devtools/__tests__/umiConfig.test.js new file mode 100644 index 00000000000..0568cf9038a --- /dev/null +++ b/packages/core/devtools/__tests__/umiConfig.test.js @@ -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(); + }); +}); diff --git a/packages/core/devtools/umiConfig.js b/packages/core/devtools/umiConfig.js index b8aaafe6687..efff41ce546 100644 --- a/packages/core/devtools/umiConfig.js +++ b/packages/core/devtools/umiConfig.js @@ -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(), }, }; } diff --git a/packages/core/sdk/src/__tests__/api-client.test.ts b/packages/core/sdk/src/__tests__/api-client.test.ts index 9f37fe694df..35bab4cb62b 100644 --- a/packages/core/sdk/src/__tests__/api-client.test.ts +++ b/packages/core/sdk/src/__tests__/api-client.test.ts @@ -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', diff --git a/packages/core/server/src/__tests__/gateway-settings.test.ts b/packages/core/server/src/__tests__/gateway-settings.test.ts new file mode 100644 index 00000000000..3aa3961fd92 --- /dev/null +++ b/packages/core/server/src/__tests__/gateway-settings.test.ts @@ -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'), 'legacy-client'); + await writeFile(path.join(packageRoot, 'dist/client/v/index.html'), 'modern-client'); + await writeFile( + path.join(packageRoot, 'dist/client/settings/index.html'), + 'settings-client', + ); + 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) { + 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'); + }); +}); diff --git a/packages/core/server/src/__tests__/gateway.utils.test.ts b/packages/core/server/src/__tests__/gateway.utils.test.ts index bd92e05c1c1..6ba8c09161f 100644 --- a/packages/core/server/src/__tests__/gateway.utils.test.ts +++ b/packages/core/server/src/__tests__/gateway.utils.test.ts @@ -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 = [ ``, @@ -62,6 +75,17 @@ describe('gateway utils', () => { ); }); + it('should rewrite standalone settings assets for public-path and CDN deployments', () => { + const html = ''; + + expect(rewriteSettingsAssetPublicPath(html, '/nocobase/settings/')).toBe( + '', + ); + expect(rewriteSettingsAssetPublicPath(html, 'https://cdn.example.com/releases/42/settings/')).toBe( + '', + ); + }); + it('should keep html unchanged for default modern public path', () => { const html = ``; expect(rewriteV2AssetPublicPath(html, `/${DIR}/`)).toBe(html); diff --git a/packages/core/server/src/gateway/index.ts b/packages/core/server/src/gateway/index.ts index 93cab413924..1c319e01203 100644 --- a/packages/core/server/src/gateway/index.ts +++ b/packages/core/server/src/gateway/index.ts @@ -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 ``; + } + + 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) { diff --git a/packages/core/server/src/gateway/utils.ts b/packages/core/server/src/gateway/utils.ts index 404a71808cb..5442f845c45 100644 --- a/packages/core/server/src/gateway/utils.ts +++ b/packages/core/server/src/gateway/utils.ts @@ -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(/]*browser-checker\.js[^>]*><\/script>/i); diff --git a/packages/plugins/@nocobase/plugin-acl/src/client-v2/__tests__/plugin.test.ts b/packages/plugins/@nocobase/plugin-acl/src/client-v2/__tests__/plugin.test.ts new file mode 100644 index 00000000000..ccb9acef32c --- /dev/null +++ b/packages/plugins/@nocobase/plugin-acl/src/client-v2/__tests__/plugin.test.ts @@ -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'); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-acl/src/client-v2/plugin.tsx b/packages/plugins/@nocobase/plugin-acl/src/client-v2/plugin.tsx index eac29d869ae..66588d63b66 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/client-v2/plugin.tsx +++ b/packages/plugins/@nocobase/plugin-acl/src/client-v2/plugin.tsx @@ -42,12 +42,6 @@ export class PluginAclClientV2 extends Plugin { 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: { diff --git a/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/SignInPage.test.tsx b/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/SignInPage.test.tsx index 4752bde6ac4..4c20db5438f 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/SignInPage.test.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/SignInPage.test.tsx @@ -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( @@ -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( + + + , + ); + + expect(navigateMock).toHaveBeenCalledWith( + { + pathname: '/signin', + search: '?redirect=%2Fnocobase%2Fsettings%2Fapps%2Fsub', + }, + { replace: true }, + ); + }); }); diff --git a/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/auth-route-navigation.test.tsx b/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/auth-route-navigation.test.tsx new file mode 100644 index 00000000000..5014ac0e519 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/auth-route-navigation.test.tsx @@ -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 = { + '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(); + 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(); + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + expect(screen.getByRole('link', { name: 'Back to login' })).toHaveAttribute('href', '/settings/signin'); + }); + + it('returns from reset-password to the registered Settings signin route', () => { + render( + + + , + ); + + expect(screen.getByRole('link', { name: 'Go to login' })).toHaveAttribute('href', '/settings/signin'); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/hooks.test.tsx b/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/hooks.test.tsx index bfa4b81dc4d..5b0b5868d32 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/hooks.test.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/hooks.test.tsx @@ -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, request: vi.fn().mockResolvedValue({ data: {} }) as ReturnType, })); @@ -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(); + 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 () => { diff --git a/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/plugin.test.tsx b/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/plugin.test.tsx index 34ca6e21789..5da47c67f3e 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/plugin.test.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/plugin.test.tsx @@ -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({ diff --git a/packages/plugins/@nocobase/plugin-auth/src/client-v2/authRoutePaths.ts b/packages/plugins/@nocobase/plugin-auth/src/client-v2/authRoutePaths.ts new file mode 100644 index 00000000000..95dccc87211 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-auth/src/client-v2/authRoutePaths.ts @@ -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 = { + '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('') : '/'; +} diff --git a/packages/plugins/@nocobase/plugin-auth/src/client-v2/forms/BasicAuthAdminSettings.tsx b/packages/plugins/@nocobase/plugin-auth/src/client-v2/forms/BasicAuthAdminSettings.tsx index 9c0a498a9d0..2eb096a15ec 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client-v2/forms/BasicAuthAdminSettings.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client-v2/forms/BasicAuthAdminSettings.tsx @@ -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={ {t('No notification channels found. Please ')} - {t('add one first')}. + + {t('add one first')} + + . } /> diff --git a/packages/plugins/@nocobase/plugin-auth/src/client-v2/forms/BasicSignInForm.tsx b/packages/plugins/@nocobase/plugin-auth/src/client-v2/forms/BasicSignInForm.tsx index 2cf1f05ed96..8bce72af30c 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client-v2/forms/BasicSignInForm.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client-v2/forms/BasicSignInForm.tsx @@ -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 (
- {allowSignUp ? {t('Create an account')} : null} + {allowSignUp ? {t('Create an account')} : null} {showForgotPassword ? ( - {t('Forgot password')} + {t('Forgot password')} ) : null}
diff --git a/packages/plugins/@nocobase/plugin-auth/src/client-v2/forms/BasicSignUpForm.tsx b/packages/plugins/@nocobase/plugin-auth/src/client-v2/forms/BasicSignUpForm.tsx index a6409f3605b..59db888a840 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client-v2/forms/BasicSignUpForm.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client-v2/forms/BasicSignUpForm.tsx @@ -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')} - {t('Log in with an existing account')} + {t('Log in with an existing account')} ); } diff --git a/packages/plugins/@nocobase/plugin-auth/src/client-v2/hooks.ts b/packages/plugins/@nocobase/plugin-auth/src/client-v2/hooks.ts index 2be78761e43..42a82bee69d 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client-v2/hooks.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/client-v2/hooks.ts @@ -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) { diff --git a/packages/plugins/@nocobase/plugin-auth/src/client-v2/pages/ForgotPasswordPage.tsx b/packages/plugins/@nocobase/plugin-auth/src/client-v2/pages/ForgotPasswordPage.tsx index 70a36796692..44a8eea2a60 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client-v2/pages/ForgotPasswordPage.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client-v2/pages/ForgotPasswordPage.tsx @@ -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 ; + return ; } return ( @@ -65,7 +67,7 @@ export default function ForgotPasswordPage() { {t('Send reset email')} - {t('Back to login')} + {t('Back to login')} ); } diff --git a/packages/plugins/@nocobase/plugin-auth/src/client-v2/pages/ResetPasswordPage.tsx b/packages/plugins/@nocobase/plugin-auth/src/client-v2/pages/ResetPasswordPage.tsx index 2972a613be8..118ec9ff9a8 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client-v2/pages/ResetPasswordPage.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client-v2/pages/ResetPasswordPage.tsx @@ -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 ; + return ; } if (!checking && (!resetToken || expired)) { @@ -70,7 +72,7 @@ export default function ResetPasswordPage() { status="403" title={t('Reset link has expired')} extra={ - } @@ -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')} - {t('Go to login')} + {t('Go to login')} ); } diff --git a/packages/plugins/@nocobase/plugin-auth/src/client-v2/pages/SignInPage.tsx b/packages/plugins/@nocobase/plugin-auth/src/client-v2/pages/SignInPage.tsx index 4f69db45bdd..2f262a73abe 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client-v2/pages/SignInPage.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client-v2/pages/SignInPage.tsx @@ -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 = Record; @@ -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; } diff --git a/packages/plugins/@nocobase/plugin-auth/src/client-v2/plugin.tsx b/packages/plugins/@nocobase/plugin-auth/src/client-v2/plugin.tsx index 07194e0daea..ce46155cbda 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client-v2/plugin.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client-v2/plugin.tsx @@ -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(() => undefined); } diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/utils/__tests__/buildRedirectPath.test.ts b/packages/plugins/@nocobase/plugin-auth/src/server/utils/__tests__/buildRedirectPath.test.ts index eb6d3e2590c..c53b2128d33 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/server/utils/__tests__/buildRedirectPath.test.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/server/utils/__tests__/buildRedirectPath.test.ts @@ -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', + ); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/utils/buildRedirectPath.ts b/packages/plugins/@nocobase/plugin-auth/src/server/utils/buildRedirectPath.ts index ea78802b6fa..4c0c33bb730 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/server/utils/buildRedirectPath.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/server/utils/buildRedirectPath.ts @@ -98,6 +98,25 @@ export interface ResolveSigninPrefixOptions { subAppSegment?: string | null; } +function resolveSettingsSigninPrefix(appPublicPath: string, redirect?: string | null): string | null { + if (typeof redirect !== 'string') { + return null; + } + + const [pathname] = redirect.split(/[?#]/, 1); + if (appPublicPath && pathname !== appPublicPath && !pathname.startsWith(`${appPublicPath}/`)) { + return null; + } + + const pathnameWithinPublicPath = pathname.slice(appPublicPath.length); + const scopedMatch = pathnameWithinPublicPath.match(/^\/settings\/((?:apps|_app)\/[^/]+)(?:\/|$)/); + if (scopedMatch) { + return `${appPublicPath}/settings/${scopedMatch[1]}`; + } + + return /^\/settings(?:\/|$)/.test(pathnameWithinPublicPath) ? `${appPublicPath}/settings` : null; +} + /** * On SSO failure the user must land back on the signin page of the *same* * shell they started from (legacy v1 vs modern v2). Modern-client URLs always @@ -108,6 +127,11 @@ export interface ResolveSigninPrefixOptions { */ export function resolveSigninPrefix({ appPublicPath, redirect, subAppSegment }: ResolveSigninPrefixOptions): string { const normalizedAppPublicPath = (appPublicPath || '').replace(/\/+$/, ''); + const settingsSigninPrefix = resolveSettingsSigninPrefix(normalizedAppPublicPath, redirect); + if (settingsSigninPrefix) { + return settingsSigninPrefix; + } + const modernSegment = `/${getModernClientPrefix()}`; const modernMarker = `${normalizedAppPublicPath}${modernSegment}`; const isModernOrigin = diff --git a/packages/plugins/@nocobase/plugin-client/src/server/__tests__/appPortals.test.ts b/packages/plugins/@nocobase/plugin-client/src/server/__tests__/appPortals.test.ts index d10129ea002..752ea1ccb50 100644 --- a/packages/plugins/@nocobase/plugin-client/src/server/__tests__/appPortals.test.ts +++ b/packages/plugins/@nocobase/plugin-client/src/server/__tests__/appPortals.test.ts @@ -55,4 +55,73 @@ describe('listAppPortals', () => { }, ]); }); + + it('returns only real portal manifests and preserves portal type per app', async () => { + const getAppManifests = vi.fn(async () => ({ + alpha: [ + { + uid: 'alpha-ai', + title: 'Alpha AI', + icon: 'RobotOutlined', + portalType: 'ai', + routePath: '/assistant', + layout: 'desktop', + }, + ], + main: [ + { + uid: 'admin-layout-model', + title: 'Desktop', + icon: 'DesktopOutlined', + portalType: 'no-code', + routePath: '/admin', + layout: 'desktop', + }, + ], + })); + vi.spyOn(AppSupervisor, 'getInstance').mockReturnValue({ + getAppSsoIssuer: () => undefined, + getAppsStatuses: vi.fn(async () => ({ + alpha: 'running', + main: 'running', + })), + getAppManifests, + listAppModels: vi.fn(async () => [ + { + name: 'alpha', + title: 'Alpha', + }, + ]), + } as unknown as AppSupervisor); + + const result = await listAppPortals('main'); + + expect(getAppManifests).toHaveBeenCalledWith('multi-portal', ['main', 'alpha']); + expect(result.portals).toEqual([ + { + uid: 'admin-layout-model', + appName: 'main', + title: 'Desktop', + icon: 'DesktopOutlined', + portalType: 'no-code', + routePath: '/admin', + layout: 'desktop', + }, + { + uid: 'alpha-ai', + appName: 'alpha', + title: 'Alpha AI', + icon: 'RobotOutlined', + portalType: 'ai', + routePath: '/assistant', + layout: 'desktop', + }, + ]); + expect(result.portals).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ uid: '__default_admin__' }), + expect.objectContaining({ uid: '__default_mobile__' }), + ]), + ); + }); }); diff --git a/packages/plugins/@nocobase/plugin-client/src/server/appPortals.ts b/packages/plugins/@nocobase/plugin-client/src/server/appPortals.ts index 7df4a8b43a5..4391dfa851e 100644 --- a/packages/plugins/@nocobase/plugin-client/src/server/appPortals.ts +++ b/packages/plugins/@nocobase/plugin-client/src/server/appPortals.ts @@ -28,33 +28,15 @@ export type AppPortalItem = { appName: string; title?: string | null; icon?: string | null; + portalType?: string | null; routePath: string; layout?: string | null; - defaultPortal?: boolean; }; -export type StoredAppPortalItem = Omit; +export type StoredAppPortalItem = Omit; const MAIN_APP_NAME = 'main'; const MULTI_PORTAL_MANIFEST_NAMESPACE = 'multi-portal'; -const DEFAULT_PORTALS: Array> = [ - { - uid: '__default_admin__', - title: 'Admin', - icon: 'DesktopOutlined', - routePath: '/admin', - layout: 'desktop', - defaultPortal: true, - }, - { - uid: '__default_mobile__', - title: 'Mobile', - icon: 'MobileOutlined', - routePath: '/mobile', - layout: 'mobile', - defaultPortal: true, - }, -]; function getCname(cname?: string | null) { const trimmed = cname?.trim(); @@ -109,17 +91,6 @@ function addPortal(portals: Map, item: AppPortalItem) { portals.set(getPortalKey(item), item); } -function addDefaultPortals(portals: Map, appNames: Set) { - for (const appName of appNames) { - for (const portal of DEFAULT_PORTALS) { - addPortal(portals, { - ...portal, - appName, - }); - } - } -} - function addStoredPortals( portals: Map, appName: string, @@ -138,6 +109,7 @@ function addStoredPortals( appName, title: typeof portal.title === 'string' ? portal.title : null, icon: typeof portal.icon === 'string' ? portal.icon : null, + portalType: typeof portal.portalType === 'string' ? portal.portalType : null, routePath: portal.routePath, layout: typeof portal.layout === 'string' ? portal.layout : null, }); @@ -184,7 +156,6 @@ export async function listAppPortals(currentAppName?: string | null) { ); const portals = new Map(); - addDefaultPortals(portals, appNames); for (const appName of appNames) { addStoredPortals(portals, appName, manifests[appName]); } diff --git a/packages/plugins/@nocobase/plugin-field-markdown-vditor/src/client-v2/components/Edit.tsx b/packages/plugins/@nocobase/plugin-field-markdown-vditor/src/client-v2/components/Edit.tsx index 3165827d20c..02703f5b157 100644 --- a/packages/plugins/@nocobase/plugin-field-markdown-vditor/src/client-v2/components/Edit.tsx +++ b/packages/plugins/@nocobase/plugin-field-markdown-vditor/src/client-v2/components/Edit.tsx @@ -104,16 +104,6 @@ export const Edit = (props) => { fileCollectionName: fileCollection, }); - if (!checkData?.data?.isSupportToUploadFiles) { - vditor.tip( - t('vditor.uploadError.message', { - storageTitle: checkData?.data?.storage?.title, - }), - 0, - ); - return; - } - vditor.tip(flowCtx.t('uploading'), 0); const { data, errorMessage } = await fileManagerPlugin.uploadFile({ file, diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.mock-server.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.mock-server.ts index 74570beef33..1404b74e953 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.mock-server.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.mock-server.ts @@ -320,7 +320,7 @@ async function createFlowSurfacesDatabaseIsolation( ...(database || {}), database: databaseName, dialect, - username: getFlowSurfacesMySqlAdminUser(), + username: getFlowSurfacesMySqlAdminUser(database), password: getFlowSurfacesMySqlAdminPassword(database), }, shouldCleanDbOnDestroy: true, @@ -442,7 +442,7 @@ async function withFlowSurfacesMySqlConnection( const connectionOptions = { host: String(database?.host || process.env.DB_HOST || '127.0.0.1'), port: Number(database?.port || process.env.DB_PORT || 3306), - user: getFlowSurfacesMySqlAdminUser(), + user: getFlowSurfacesMySqlAdminUser(database), password: getFlowSurfacesMySqlAdminPassword(database), }; const connection = @@ -457,8 +457,8 @@ async function withFlowSurfacesMySqlConnection( } } -function getFlowSurfacesMySqlAdminUser() { - return 'root'; +function getFlowSurfacesMySqlAdminUser(database: MockServerOptions['database'] | undefined) { + return String(database?.username || process.env.DB_USER || process.env.DB_USERNAME || 'root'); } function getFlowSurfacesMySqlAdminPassword(database: MockServerOptions['database'] | undefined) { diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.multi-portal.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.multi-portal.test.ts index d88ba97bff1..b6371fb40f0 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.multi-portal.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.multi-portal.test.ts @@ -7,7 +7,9 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import type { Model } from '@nocobase/database'; import type { MockServer } from '@nocobase/test'; +import type { FlowSurfaceNavigationTarget } from '../flow-surfaces/navigation-targets'; import { FlowSurfacesService } from '../flow-surfaces/service'; import { getData } from './flow-surfaces.contract.helpers'; import { createFlowSurfacesMockServer, loginFlowSurfacesRootAgent } from './flow-surfaces.mock-server'; @@ -15,10 +17,12 @@ import { FLOW_SURFACES_TEST_PLUGIN_INSTALLS, FLOW_SURFACES_TEST_PLUGINS } from ' const ADMIN_LAYOUT_UID = 'admin-layout-model'; const MOBILE_LAYOUT_UID = 'mobile-layout-model'; +const ADMIN_LAYOUT_PORTAL_UID = '__default_admin__'; const DESKTOP_PORTAL_UID = 'flow-surfaces-desktop-portal'; const SECOND_DESKTOP_PORTAL_UID = 'flow-surfaces-second-desktop-portal'; const MOBILE_PORTAL_UID = 'flow-surfaces-mobile-portal'; const DISABLED_PORTAL_UID = 'flow-surfaces-disabled-portal'; +const AI_PORTAL_UID = 'flow-surfaces-ai-portal'; const PORTAL_ROLE_NAME = 'flow-surfaces-portal-author'; function registerMultiPortalFixture(app: MockServer) { @@ -31,7 +35,8 @@ function registerMultiPortalFixture(app: MockServer) { { name: 'uid', type: 'string', primaryKey: true, allowNull: false }, { name: 'title', type: 'string', allowNull: false }, { name: 'icon', type: 'string' }, - { name: 'routeName', type: 'string', unique: true, allowNull: false }, + { name: 'portalType', type: 'string', allowNull: false }, + { name: 'portalName', field: 'routeName', type: 'string', unique: true, allowNull: false }, { name: 'routePath', type: 'string', allowNull: false }, { name: 'authCheck', type: 'boolean', defaultValue: true, allowNull: false }, { name: 'enabled', type: 'boolean', defaultValue: true, allowNull: false }, @@ -207,11 +212,23 @@ describe('flowSurfaces Multi-portal integration', () => { rootAgent = await loginFlowSurfacesRootAgent(app); service = new FlowSurfacesService(app.pm.get('flow-engine') as any); + await createPortal(app, { + uid: ADMIN_LAYOUT_PORTAL_UID, + title: 'Desktop layout portal', + icon: 'DesktopOutlined', + portalType: 'no-code', + portalName: 'admin', + routePath: '/admin', + authCheck: true, + enabled: true, + uiLayoutUid: ADMIN_LAYOUT_UID, + }); await createPortal(app, { uid: DESKTOP_PORTAL_UID, title: 'Operations workspace', icon: 'DashboardOutlined', - routeName: 'flowSurfacesOperations', + portalType: 'no-code', + portalName: 'flowSurfacesOperations', routePath: '/flow-surfaces-operations', authCheck: true, enabled: true, @@ -221,7 +238,8 @@ describe('flowSurfaces Multi-portal integration', () => { uid: SECOND_DESKTOP_PORTAL_UID, title: 'Secondary workspace', icon: 'ProjectOutlined', - routeName: 'flowSurfacesSecondary', + portalType: 'no-code', + portalName: 'flowSurfacesSecondary', routePath: '/flow-surfaces-secondary', authCheck: true, enabled: true, @@ -231,7 +249,8 @@ describe('flowSurfaces Multi-portal integration', () => { uid: MOBILE_PORTAL_UID, title: 'Mobile workspace', icon: 'MobileOutlined', - routeName: 'flowSurfacesMobile', + portalType: 'no-code', + portalName: 'flowSurfacesMobile', routePath: '/flow-surfaces-mobile', authCheck: true, enabled: true, @@ -240,13 +259,24 @@ describe('flowSurfaces Multi-portal integration', () => { await createPortal(app, { uid: DISABLED_PORTAL_UID, title: 'Disabled workspace', - routeName: 'flowSurfacesDisabled', + portalType: 'no-code', + portalName: 'flowSurfacesDisabled', routePath: '/flow-surfaces-disabled', authCheck: true, enabled: false, uiLayoutUid: ADMIN_LAYOUT_UID, }); - + await createPortal(app, { + uid: AI_PORTAL_UID, + title: 'AI workspace', + icon: 'RobotOutlined', + portalType: 'ai', + portalName: 'flowSurfacesAi', + routePath: '/flow-surfaces-ai', + authCheck: true, + enabled: true, + uiLayoutUid: ADMIN_LAYOUT_UID, + }); await app.db.getRepository('roles').create({ values: { name: PORTAL_ROLE_NAME, @@ -286,8 +316,15 @@ describe('flowSurfaces Multi-portal integration', () => { expect(targets.capabilities).toEqual({ multiPortal: true }); expect(targets.targets).toEqual( expect.arrayContaining([ - expect.objectContaining({ kind: 'layout', uid: ADMIN_LAYOUT_UID, default: true }), + expect.objectContaining({ kind: 'layout', uid: ADMIN_LAYOUT_UID }), expect.objectContaining({ kind: 'layout', uid: MOBILE_LAYOUT_UID, layoutType: 'mobile' }), + expect.objectContaining({ + kind: 'portal', + uid: ADMIN_LAYOUT_PORTAL_UID, + portalUid: ADMIN_LAYOUT_PORTAL_UID, + layoutUid: ADMIN_LAYOUT_UID, + default: true, + }), expect.objectContaining({ kind: 'portal', uid: DESKTOP_PORTAL_UID, @@ -302,12 +339,129 @@ describe('flowSurfaces Multi-portal integration', () => { }), ]), ); + expect( + targets.targets.find( + (target: FlowSurfaceNavigationTarget) => target.kind === 'layout' && target.uid === ADMIN_LAYOUT_UID, + )?.default, + ).toBe(undefined); expect(targets.targets.some((target: any) => target.uid === SECOND_DESKTOP_PORTAL_UID)).toBe(false); expect(targets.targets.some((target: any) => target.uid === DISABLED_PORTAL_UID)).toBe(false); - expect(targets.targets.some((target: any) => target.uid === '__default_admin__')).toBe(false); + expect(targets.targets.some((target: FlowSurfaceNavigationTarget) => target.uid === AI_PORTAL_UID)).toBe(false); expect(targets.targets.some((target: any) => target.uid === '__default_mobile__')).toBe(false); }); + it('should create fixed Admin Portal routes through the backing layout permission model', async () => { + const groupTitle = `Layout-mode portal group ${Date.now()}`; + const pageTitle = `Layout-mode portal page ${Date.now()}`; + const created = getData( + await rootAgent.resource('flowSurfaces').applyBlueprint({ + values: buildMarkdownBlueprint(ADMIN_LAYOUT_PORTAL_UID, groupTitle, pageTitle, 2), + }), + ); + const groupRoute = await app.db.getRepository('desktopRoutes').findOne({ + filter: { type: 'group', title: groupTitle }, + }); + const groupRouteId = groupRoute?.get('id'); + const pageScope = await readRouteScope(app, created.surface.pageRoute.id); + const groupScope = await readRouteScope(app, groupRouteId); + const tabRoutes = pageScope.route?.get('children') || []; + const routeIds = [groupRouteId, created.surface.pageRoute.id, ...tabRoutes.map((tab: Model) => tab.get('id'))]; + + expect(groupScope.layoutUids).toEqual([ADMIN_LAYOUT_UID]); + expect(groupScope.portalUids).toEqual([]); + expect(pageScope.layoutUids).toEqual([ADMIN_LAYOUT_UID]); + expect(pageScope.portalUids).toEqual([]); + expect(tabRoutes).toHaveLength(2); + for (const tabRoute of tabRoutes) { + const tabScope = await readRouteScope(app, tabRoute.get('id')); + expect(tabScope.layoutUids).toEqual([ADMIN_LAYOUT_UID]); + expect(tabScope.portalUids).toEqual([]); + } + + const standardPermissions = await app.db.getRepository('rolesDesktopRoutes').find({ + filter: { + roleName: PORTAL_ROLE_NAME, + desktopRouteId: routeIds, + }, + }); + expect(standardPermissions).toHaveLength(routeIds.length); + const portalPermissions = await app.db.getRepository('rolesMultiPortalDesktopRoutes').find({ + filter: { + multiPortalUid: ADMIN_LAYOUT_PORTAL_UID, + desktopRouteId: routeIds, + }, + }); + expect(portalPermissions).toHaveLength(0); + + const roleCreated = await service.transaction((transaction) => + service.createMenu( + { + type: 'group', + title: `Layout-mode role group ${Date.now()}`, + icon: 'AppstoreOutlined', + portalUid: ADMIN_LAYOUT_PORTAL_UID, + }, + { + transaction, + currentRoles: [PORTAL_ROLE_NAME], + }, + ), + ); + expect((await readRouteScope(app, roleCreated.routeId)).layoutUids).toEqual([ADMIN_LAYOUT_UID]); + expect((await readRouteScope(app, roleCreated.routeId)).portalUids).toEqual([]); + + const updated = getData( + await rootAgent.resource('flowSurfaces').updateMenu({ + values: { + menuRouteId: groupRouteId, + portalUid: ADMIN_LAYOUT_PORTAL_UID, + title: `${groupTitle} updated`, + }, + }), + ); + expect(updated.routeId).toBe(groupRouteId); + + const portalOnlyParent = getData( + await rootAgent.resource('flowSurfaces').createMenu({ + values: { + type: 'group', + title: `Portal-only parent ${Date.now()}`, + icon: 'AppstoreOutlined', + portalUid: DESKTOP_PORTAL_UID, + }, + }), + ); + const mismatch = await rootAgent.resource('flowSurfaces').createMenu({ + values: { + type: 'item', + title: `Layout-mode mismatch ${Date.now()}`, + icon: 'FileOutlined', + parentMenuRouteId: portalOnlyParent.routeId, + portalUid: ADMIN_LAYOUT_PORTAL_UID, + }, + }); + expect(mismatch.status).toBe(400); + expect(mismatch.body?.errors?.[0]?.ruleId).toBe('navigation-route-layout-mismatch'); + }); + + it('should prefer the canonical Admin no-code portal for implicit route creation', async () => { + const blueprint = buildMarkdownBlueprint( + ADMIN_LAYOUT_PORTAL_UID, + `Implicit Admin portal group ${Date.now()}`, + `Implicit Admin portal page ${Date.now()}`, + ); + delete (blueprint.navigation as { portalUid?: string }).portalUid; + const created = getData( + await rootAgent.resource('flowSurfaces').applyBlueprint({ + values: blueprint, + }), + ); + + const pageScope = await readRouteScope(app, created.surface.pageRoute.id); + expect(pageScope.portalUids).toEqual([]); + expect(pageScope.layoutUids).toEqual([ADMIN_LAYOUT_UID]); + }); + it('should create desktop portal group, page and tabs as portal-only routes with role grants', async () => { const groupTitle = `Portal desktop group ${Date.now()}`; const pageTitle = `Portal desktop page ${Date.now()}`; @@ -538,6 +692,17 @@ describe('flowSurfaces Multi-portal integration', () => { expect(disabled.status).toBe(400); expect(disabled.body?.errors?.[0]?.ruleId).toBe('navigation-portal-disabled'); + const aiPortal = await rootAgent.resource('flowSurfaces').createMenu({ + values: { + type: 'group', + title: 'Unsupported AI portal group', + icon: 'AppstoreOutlined', + portalUid: AI_PORTAL_UID, + }, + }); + expect(aiPortal.status).toBe(400); + expect(aiPortal.body?.errors?.[0]?.ruleId).toBe('navigation-portal-type-unsupported'); + await expect( service.transaction((transaction) => service.createMenu( @@ -559,6 +724,38 @@ describe('flowSurfaces Multi-portal integration', () => { }); }); + it('should reject implicit no-code route creation when only an AI portal is enabled', async () => { + const noCodePortals = await app.db.getRepository('multiPortals').find({ + filter: { portalType: 'no-code' }, + fields: ['uid', 'enabled'], + }); + for (const portal of noCodePortals) { + await app.db.getRepository('multiPortals').update({ + filterByTk: portal.get('uid'), + values: { enabled: false }, + }); + } + + try { + const response = await rootAgent.resource('flowSurfaces').createMenu({ + values: { + type: 'group', + title: `AI-only implicit group ${Date.now()}`, + icon: 'AppstoreOutlined', + }, + }); + expect(response.status).toBe(400); + expect(response.body?.errors?.[0]?.ruleId).toBe('navigation-no-code-portal-not-found'); + } finally { + for (const portal of noCodePortals) { + await app.db.getRepository('multiPortals').update({ + filterByTk: portal.get('uid'), + values: { enabled: portal.get('enabled') }, + }); + } + } + }); + it('should roll back route, portal relation and permission writes together', async () => { const title = `Rolled back portal group ${Date.now()}`; let routeId: string | number | undefined; diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/navigation-targets.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/navigation-targets.test.ts new file mode 100644 index 00000000000..ebd1ceb0c10 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/navigation-targets.test.ts @@ -0,0 +1,213 @@ +/** + * 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 { Database } from '@nocobase/database'; +import { + DEFAULT_ADMIN_MULTI_PORTAL_UID, + DEFAULT_MOBILE_MULTI_PORTAL_UID, + FlowSurfaceNavigationTargetsService, +} from '../flow-surfaces/navigation-targets'; + +type PortalRecord = { + uid: string; + title: string; + portalType: 'no-code'; + portalName: string; + routePath: string; + authCheck: boolean; + enabled: boolean; + uiLayoutUid: string; +}; + +type FindOptions = { + appends?: string[]; + filter?: Record; +}; + +const ADMIN_LAYOUT_UID = 'admin-layout-model'; +const FALLBACK_LAYOUT_UID = 'fallback-layout-model'; + +function createPortal(uid: string, uiLayoutUid = ADMIN_LAYOUT_UID): PortalRecord { + return { + uid, + title: uid, + portalType: 'no-code', + portalName: uid.replaceAll('_', ''), + routePath: `/${uid}`, + authCheck: true, + enabled: true, + uiLayoutUid, + }; +} + +function filterPortalRecords(portals: PortalRecord[], filter: Record = {}) { + const excludedUids = Array.isArray(filter['uid.$notIn']) ? filter['uid.$notIn'] : []; + return portals.filter( + (portal) => + (typeof filter.enabled === 'undefined' || portal.enabled === filter.enabled) && + !excludedUids.includes(portal.uid), + ); +} + +function createLayout(uid = ADMIN_LAYOUT_UID, enabled = true, layoutType = 'desktop') { + return { + uid, + title: uid, + layoutType, + routeName: uid, + routePath: `/${uid}`, + authCheck: true, + enabled, + }; +} + +function createDatabase(portals: PortalRecord[], layouts = [createLayout()]) { + const repositories = { + multiPortals: { + find: vi.fn(async (options: FindOptions = {}) => { + const records = filterPortalRecords(portals, options.filter); + if (!options.appends?.includes('uiLayout')) { + return records; + } + return records.map((portal) => ({ + ...portal, + uiLayout: layouts.find((layout) => layout.uid === portal.uiLayoutUid), + })); + }), + findOne: vi.fn(async (options: FindOptions = {}) => { + const uid = options.filter?.uid; + return portals.find((portal) => portal.uid === uid); + }), + }, + uiLayouts: { + find: vi.fn(async (options: FindOptions = {}) => + layouts.filter( + (layout) => typeof options.filter?.enabled === 'undefined' || layout.enabled === options.filter.enabled, + ), + ), + findOne: vi.fn(async (options: FindOptions = {}) => { + const uid = options.filter?.uid; + const enabled = options.filter?.enabled; + return layouts.find( + (layout) => layout.uid === uid && (typeof enabled === 'undefined' || enabled === layout.enabled), + ); + }), + }, + }; + const collections = { + multiPortals: {}, + uiLayouts: {}, + desktopRoutes: { + getField: (name: string) => (name === 'multiPortals' || name === 'uiLayouts' ? {} : undefined), + }, + }; + + return { + getCollection: vi.fn((name: keyof typeof collections) => collections[name]), + getRepository: vi.fn((name: keyof typeof repositories) => repositories[name]), + } as unknown as Database; +} + +describe('FlowSurfaceNavigationTargetsService portal identity', () => { + const legacyNamedPortalUids = [DEFAULT_ADMIN_MULTI_PORTAL_UID, DEFAULT_MOBILE_MULTI_PORTAL_UID]; + + it('lists persisted portals even when their UIDs look like legacy virtual defaults', async () => { + const service = new FlowSurfaceNavigationTargetsService( + createDatabase(legacyNamedPortalUids.map((uid) => createPortal(uid))), + ); + + const targets = await service.listNavigationTargets(['root']); + + expect(targets.targets.filter((target) => target.kind === 'portal').map((target) => target.uid)).toEqual( + legacyNamedPortalUids, + ); + }); + + it.each(legacyNamedPortalUids)( + 'resolves fixed portal %s through its backing Layout without a mode field', + async (portalUid) => { + const service = new FlowSurfaceNavigationTargetsService(createDatabase([createPortal(portalUid)])); + + const resolved = await service.resolvePortal(portalUid, { + actionName: 'createMenu', + path: 'portalUid', + currentRoles: ['root'], + }); + + expect(resolved).toMatchObject({ + uid: portalUid, + layoutUid: ADMIN_LAYOUT_UID, + }); + expect(resolved).not.toHaveProperty('routePermissionMode'); + }, + ); + + it.each(['default_admin', 'default_mobile', 'admin-layout-model', '__default_admin__-copy'])( + 'treats similar uid %s as a regular Portal', + async (portalUid) => { + const service = new FlowSurfaceNavigationTargetsService(createDatabase([createPortal(portalUid)])); + + const resolved = await service.resolvePortal(portalUid, { + actionName: 'createMenu', + path: 'portalUid', + currentRoles: ['root'], + }); + + expect(resolved).toMatchObject({ uid: portalUid, layoutUid: ADMIN_LAYOUT_UID }); + expect(resolved).not.toHaveProperty('routePermissionMode'); + }, + ); + + it.each([ + ['missing', [createLayout(FALLBACK_LAYOUT_UID)]], + ['disabled', [createLayout(ADMIN_LAYOUT_UID, false), createLayout(FALLBACK_LAYOUT_UID)]], + ])('skips a higher-priority portal whose backing layout is %s', async (_case, layouts) => { + const fallbackPortalUid = 'fallback-portal'; + const service = new FlowSurfaceNavigationTargetsService( + createDatabase( + [createPortal(DEFAULT_ADMIN_MULTI_PORTAL_UID), createPortal(fallbackPortalUid, FALLBACK_LAYOUT_UID)], + layouts, + ), + ); + + await expect( + service.resolveDefaultPortal({ + actionName: 'createMenu', + currentRoles: ['root'], + }), + ).resolves.toMatchObject({ + uid: fallbackPortalUid, + layoutUid: FALLBACK_LAYOUT_UID, + }); + }); + + it('prefers a custom Desktop portal over a lexically earlier custom Mobile portal', async () => { + const mobileLayoutUid = 'custom-mobile-layout'; + const desktopLayoutUid = 'custom-desktop-layout'; + const mobilePortal = createPortal('a-mobile-portal', mobileLayoutUid); + const desktopPortal = createPortal('z-desktop-portal', desktopLayoutUid); + const service = new FlowSurfaceNavigationTargetsService( + createDatabase( + [mobilePortal, desktopPortal], + [createLayout(mobileLayoutUid, true, 'mobile'), createLayout(desktopLayoutUid, true, 'desktop')], + ), + ); + + await expect( + service.resolveDefaultPortal({ + actionName: 'createMenu', + currentRoles: ['root'], + }), + ).resolves.toMatchObject({ + uid: desktopPortal.uid, + layoutUid: desktopLayoutUid, + layoutType: 'desktop', + }); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/navigation-targets.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/navigation-targets.ts index d0b33767125..f6be3250ef8 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/navigation-targets.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/navigation-targets.ts @@ -15,9 +15,8 @@ export const DEFAULT_MOBILE_UI_LAYOUT_UID = 'mobile-layout-model'; export const DEFAULT_ADMIN_MULTI_PORTAL_UID = '__default_admin__'; export const DEFAULT_MOBILE_MULTI_PORTAL_UID = '__default_mobile__'; -const RESERVED_MULTI_PORTAL_UIDS = new Set([DEFAULT_ADMIN_MULTI_PORTAL_UID, DEFAULT_MOBILE_MULTI_PORTAL_UID]); - export type FlowSurfaceNavigationRequestRoles = readonly string[] | string; +type FlowSurfacePortalRouteScopeKind = 'layout' | 'portal'; export type FlowSurfaceResolvedMultiPortal = { uid: string; @@ -29,6 +28,8 @@ export type FlowSurfaceResolvedMultiPortal = { enabled: true; layoutUid: string; layoutType?: string; + portalType: 'no-code'; + routeScopeKind: FlowSurfacePortalRouteScopeKind; }; export type FlowSurfaceNavigationTarget = { @@ -85,6 +86,10 @@ function normalizeRelationRouteId(routeId: unknown): TargetKey | undefined { return routeId === null || typeof routeId === 'undefined' ? undefined : (routeId as TargetKey); } +function isDefaultLayoutMultiPortalUid(uid: unknown) { + return uid === DEFAULT_ADMIN_MULTI_PORTAL_UID || uid === DEFAULT_MOBILE_MULTI_PORTAL_UID; +} + export class FlowSurfaceNavigationTargetsService { constructor(private readonly db: Database) {} @@ -115,9 +120,13 @@ export class FlowSurfaceNavigationTargetsService { currentRoles?: FlowSurfaceNavigationRequestRoles, transaction?: Transaction, ): Promise { - const layoutTargets = await this.listLayoutTargets(transaction); + let layoutTargets = await this.listLayoutTargets(transaction); const multiPortal = this.hasMultiPortalCapability(); - const portalTargets = multiPortal ? await this.listAccessiblePortalTargets(currentRoles, transaction) : []; + let portalTargets = multiPortal ? await this.listAccessiblePortalTargets(currentRoles, transaction) : []; + if (multiPortal) { + layoutTargets = layoutTargets.map(({ default: _default, ...target }) => target); + portalTargets = portalTargets.map((target, index) => (index === 0 ? { ...target, default: true } : target)); + } return { version: '1', capabilities: { @@ -127,6 +136,71 @@ export class FlowSurfaceNavigationTargetsService { }; } + async resolveDefaultPortal(options: Omit): Promise { + if (!this.hasMultiPortalCapability()) { + throwBadRequest(`flowSurfaces ${options.actionName} requires the Multi-portal capability`, { + ruleId: 'navigation-portal-unsupported', + path: 'navigation', + }); + } + + const portals = await this.db.getRepository('multiPortals').find({ + filter: { + enabled: true, + }, + fields: ['uid', 'portalType', 'portalName', 'routePath', 'uiLayoutUid'], + appends: ['uiLayout'], + sort: ['uid'], + transaction: options.transaction, + }); + const candidates = portals + .filter((portal: unknown) => readStringField(portal, 'portalType') === 'no-code') + .sort((left: unknown, right: unknown) => this.comparePortalPriority(left, right)); + if (!candidates.length) { + throwBadRequest(`flowSurfaces ${options.actionName} requires an enabled no-code portal`, { + ruleId: 'navigation-no-code-portal-not-found', + path: 'navigation', + }); + } + + for (const candidate of candidates) { + const portalUid = readStringField(candidate, 'uid'); + if (!portalUid) { + continue; + } + const layoutUid = readStringField(candidate, 'uiLayoutUid'); + if (!layoutUid || !this.db.getCollection('uiLayouts')) { + continue; + } + const layout = await this.db.getRepository('uiLayouts').findOne({ + filter: { uid: layoutUid, enabled: true }, + fields: ['uid'], + transaction: options.transaction, + }); + if (!layout) { + continue; + } + if ( + isDefaultLayoutMultiPortalUid(portalUid) || + (await this.canAccessPortal(portalUid, options.currentRoles, options.transaction)) + ) { + return this.resolvePortal(portalUid, { + ...options, + path: 'navigation', + }); + } + } + + throwForbidden( + `flowSurfaces ${options.actionName} current roles cannot access an enabled no-code portal`, + 'FLOW_SURFACE_NAVIGATION_PORTAL_FORBIDDEN', + { + ruleId: 'navigation-portal-forbidden', + path: 'navigation', + }, + ); + } + async resolvePortal(portalUidValue: unknown, options: PortalResolveOptions): Promise { const portalUid = this.normalizePortalUid(portalUidValue); if (!portalUid) { @@ -142,20 +216,9 @@ export class FlowSurfaceNavigationTargetsService { details: { portalUid }, }); } - if (RESERVED_MULTI_PORTAL_UIDS.has(portalUid)) { - throwBadRequest( - `flowSurfaces ${options.actionName} ${options.path} cannot use reserved default portal uid '${portalUid}'; use layoutUid semantics instead`, - { - ruleId: 'navigation-portal-reserved', - path: options.path, - details: { portalUid }, - }, - ); - } - const portal = await this.db.getRepository('multiPortals').findOne({ filter: { uid: portalUid }, - fields: ['uid', 'title', 'icon', 'routeName', 'routePath', 'authCheck', 'enabled', 'uiLayoutUid'], + fields: ['uid', 'title', 'icon', 'portalType', 'portalName', 'routePath', 'authCheck', 'enabled', 'uiLayoutUid'], transaction: options.transaction, }); if (!portal) { @@ -172,7 +235,14 @@ export class FlowSurfaceNavigationTargetsService { details: { portalUid }, }); } - + const portalType = readStringField(portal, 'portalType'); + if (portalType !== 'no-code') { + throwBadRequest(`flowSurfaces ${options.actionName} portal '${portalUid}' does not support no-code routes`, { + ruleId: 'navigation-portal-type-unsupported', + path: options.path, + details: { portalUid, portalType: portalType || null }, + }); + } const layoutUid = readStringField(portal, 'uiLayoutUid'); if (!layoutUid || !this.db.getCollection('uiLayouts')) { throwBadRequest(`flowSurfaces ${options.actionName} portal '${portalUid}' has no available backing UI layout`, { @@ -206,7 +276,13 @@ export class FlowSurfaceNavigationTargetsService { }, ); } - if (!(await this.canAccessPortal(portalUid, options.currentRoles, options.transaction))) { + const routeScopeKind: FlowSurfacePortalRouteScopeKind = isDefaultLayoutMultiPortalUid(portalUid) + ? 'layout' + : 'portal'; + if ( + routeScopeKind === 'portal' && + !(await this.canAccessPortal(portalUid, options.currentRoles, options.transaction)) + ) { throwForbidden( `flowSurfaces ${options.actionName} current roles cannot access portal '${portalUid}'`, 'FLOW_SURFACE_NAVIGATION_PORTAL_FORBIDDEN', @@ -222,12 +298,14 @@ export class FlowSurfaceNavigationTargetsService { uid: portalUid, title: readStringField(portal, 'title') || portalUid, icon: readStringField(portal, 'icon') || null, - routeName: readStringField(portal, 'routeName'), + routeName: readStringField(portal, 'portalName'), routePath: readStringField(portal, 'routePath'), authCheck: readRecordField(portal, 'authCheck') === true, enabled: true, layoutUid, layoutType: readStringField(layout, 'layoutType'), + portalType, + routeScopeKind, }; } @@ -358,36 +436,25 @@ export class FlowSurfaceNavigationTargetsService { transaction?: Transaction, ): Promise { const roles = normalizeRoles(currentRoles); - let accessiblePortalUids: string[] | undefined; - if (!roles.includes('root')) { - if (!roles.length || !this.db.getCollection('rolesMultiPortals')) { - return []; - } + const isRoot = roles.includes('root'); + const accessiblePortalUids = new Set(); + if (!isRoot && roles.length && this.db.getCollection('rolesMultiPortals')) { const grants = await this.db.getRepository('rolesMultiPortals').find({ fields: ['multiPortalUid'], filter: { roleName: roles }, transaction, }); - accessiblePortalUids = Array.from( - new Set( - grants - .map((grant: unknown) => readStringField(grant, 'multiPortalUid')) - .filter((portalUid: string | undefined): portalUid is string => !!portalUid), - ), - ); - if (!accessiblePortalUids.length) { - return []; + for (const grant of grants) { + const portalUid = readStringField(grant, 'multiPortalUid'); + if (portalUid) { + accessiblePortalUids.add(portalUid); + } } } - const accessiblePortalUidSet = accessiblePortalUids ? new Set(accessiblePortalUids) : undefined; - const filter: Record = { - enabled: true, - 'uid.$notIn': Array.from(RESERVED_MULTI_PORTAL_UIDS), - }; const portals = await this.db.getRepository('multiPortals').find({ - filter, - fields: ['uid', 'title', 'icon', 'routeName', 'routePath', 'authCheck', 'enabled', 'uiLayoutUid'], + filter: { enabled: true }, + fields: ['uid', 'title', 'icon', 'portalType', 'portalName', 'routePath', 'authCheck', 'enabled', 'uiLayoutUid'], sort: ['uid'], transaction, }); @@ -395,9 +462,12 @@ export class FlowSurfaceNavigationTargetsService { for (const portal of portals) { const portalUid = readStringField(portal, 'uid'); const layoutUid = readStringField(portal, 'uiLayoutUid'); + const portalType = readStringField(portal, 'portalType'); + if (!portalUid || portalType !== 'no-code') { + continue; + } if ( - !portalUid || - (accessiblePortalUidSet && !accessiblePortalUidSet.has(portalUid)) || + (!isRoot && !isDefaultLayoutMultiPortalUid(portalUid) && !accessiblePortalUids.has(portalUid)) || !layoutUid || !this.db.getCollection('uiLayouts') ) { @@ -419,12 +489,32 @@ export class FlowSurfaceNavigationTargetsService { icon: readStringField(portal, 'icon') || null, layoutUid, layoutType: readStringField(layout, 'layoutType'), - routeName: readStringField(portal, 'routeName'), + routeName: readStringField(portal, 'portalName'), routePath: readStringField(portal, 'routePath'), authCheck: readRecordField(portal, 'authCheck') === true, }); } - return targets; + return targets.sort((left, right) => this.comparePortalPriority(left, right)); + } + + private comparePortalPriority(left: unknown, right: unknown) { + const priorityDelta = this.getPortalPriority(left) - this.getPortalPriority(right); + if (priorityDelta) { + return priorityDelta; + } + return String(readStringField(left, 'uid') || '').localeCompare(String(readStringField(right, 'uid') || '')); + } + + private getPortalPriority(portal: unknown) { + const uiLayout = readRecordField(portal, 'uiLayout'); + const layoutType = readStringField(portal, 'layoutType') || readStringField(uiLayout, 'layoutType'); + if (readStringField(portal, 'uid') === DEFAULT_ADMIN_MULTI_PORTAL_UID) { + return 0; + } + if (layoutType !== 'mobile') { + return 1; + } + return 2; } private async canAccessPortal( diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/service.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/service.ts index 26572c324a1..3da1e8d2184 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/service.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/service.ts @@ -8,7 +8,7 @@ */ import { createHash } from 'crypto'; -import type { BelongsToManyRepository, HasManyRepository, TargetKey } from '@nocobase/database'; +import type { BelongsToManyRepository, HasManyRepository, Model, TargetKey, Transaction } from '@nocobase/database'; import type { Plugin } from '@nocobase/server'; import { transformSQL, uid } from '@nocobase/utils'; import _ from 'lodash'; @@ -1901,6 +1901,91 @@ export class FlowSurfacesService { }; } + private buildDesktopRouteScopeForPortal(portal: FlowSurfaceResolvedMultiPortal): FlowSurfaceDesktopRouteScope { + return { + portalUids: portal.routeScopeKind === 'portal' ? [portal.uid] : [], + layoutUids: portal.routeScopeKind === 'layout' ? [portal.layoutUid] : [], + selectedPortal: portal, + layoutType: portal.layoutType, + }; + } + + private getPortalRouteFilterUid(portal: FlowSurfaceResolvedMultiPortal | undefined) { + return portal?.routeScopeKind === 'portal' ? portal.uid : undefined; + } + + private async assertRouteBelongsToResolvedPortal( + actionName: string, + route: Model, + portal: FlowSurfaceResolvedMultiPortal, + path: string, + transaction?: Transaction, + ) { + if (portal.routeScopeKind === 'layout') { + await this.assertDesktopRouteBelongsToUiLayout(actionName, route, portal.layoutUid, path, transaction); + return; + } + await this.navigationTargets.assertRouteBelongsToPortal( + actionName, + this.readRouteField(route, 'id'), + portal.uid, + path, + transaction, + ); + } + + private async resolveDefaultDesktopRouteScope( + actionName: string, + options: { transaction?: Transaction; currentRoles?: FlowSurfaceRequestRoles } = {}, + ): Promise { + if (this.navigationTargets.hasMultiPortalCapability()) { + const portal = await this.navigationTargets.resolveDefaultPortal({ + actionName, + currentRoles: options.currentRoles, + transaction: options.transaction, + }); + return this.buildDesktopRouteScopeForPortal(portal); + } + + const layoutUids = await this.resolveDefaultDesktopRouteUiLayoutUids(options.transaction); + return { + portalUids: [], + layoutUids, + layoutType: + layoutUids.length === 1 + ? await this.getDesktopRouteUiLayoutTypeByUid(layoutUids[0], options.transaction) + : undefined, + }; + } + + private async resolveDesktopRouteMatchScope( + layoutUid: string | string[] | undefined, + portalUid: string | undefined, + actionName: string, + options: { transaction?: Transaction; currentRoles?: FlowSurfaceRequestRoles } = {}, + ) { + if (portalUid) { + const portal = await this.navigationTargets.resolvePortal(portalUid, { + actionName, + path: 'values.navigation.portalUid', + currentRoles: options.currentRoles, + transaction: options.transaction, + }); + return { + layoutUids: portal.routeScopeKind === 'layout' ? [portal.layoutUid] : undefined, + portalUid: this.getPortalRouteFilterUid(portal), + }; + } + if (this.normalizeDesktopRouteLayoutUidFilter(layoutUid).length) { + return { layoutUids: layoutUid, portalUid: undefined }; + } + const defaultScope = await this.resolveDefaultDesktopRouteScope(actionName, options); + return { + layoutUids: defaultScope.layoutUids.length ? defaultScope.layoutUids : undefined, + portalUid: this.getPortalRouteFilterUid(defaultScope.selectedPortal), + }; + } + private async resolveRequestedDesktopRouteScope( values: Record, parentRoute: any, @@ -1922,20 +2007,15 @@ export class FlowSurfacesService { transaction: options.transaction, }); if (parentRoute) { - await this.navigationTargets.assertRouteBelongsToPortal( + await this.assertRouteBelongsToResolvedPortal( actionName, - this.readRouteField(parentRoute, 'id'), - portal.uid, + parentRoute, + portal, 'values.parentMenuRouteId', options.transaction, ); } - return { - portalUids: [portal.uid], - layoutUids: [], - selectedPortal: portal, - layoutType: portal.layoutType, - }; + return this.buildDesktopRouteScopeForPortal(portal); } if (layoutUid) { @@ -1986,6 +2066,10 @@ export class FlowSurfacesService { } } + if (!parentRoute) { + return this.resolveDefaultDesktopRouteScope(actionName, options); + } + const layoutUids = await this.resolveInheritedDesktopRouteUiLayoutUids(parentRoute, options.transaction); return { portalUids: [], @@ -2018,18 +2102,14 @@ export class FlowSurfacesService { currentRoles: options.currentRoles, transaction: options.transaction, }); - await this.navigationTargets.assertRouteBelongsToPortal( + await this.assertRouteBelongsToResolvedPortal( actionName, - this.readRouteField(route, 'id'), - portal.uid, + route, + portal, 'values.menuRouteId', options.transaction, ); - return { - ...routeScope, - selectedPortal: portal, - layoutType: portal.layoutType, - }; + return this.buildDesktopRouteScopeForPortal(portal); } if (layoutUid) { @@ -2296,7 +2376,7 @@ export class FlowSurfacesService { title, options.transaction, routeScope.layoutUids, - routeScope.selectedPortal?.uid, + this.getPortalRouteFilterUid(routeScope.selectedPortal), ); if (existingGroups.length === 1) { return this.buildMenuResult(existingGroups[0]); @@ -5020,7 +5100,7 @@ export class FlowSurfacesService { private async resolveApplyBlueprintCreateNavigationGroup( document: FlowSurfaceApplyBlueprintDocument, - transaction?: any, + options: { transaction?: Transaction; currentRoles?: FlowSurfaceRequestRoles } = {}, ): Promise { if (document.mode !== 'create' || !_.isPlainObject(document.navigation?.group)) { return document; @@ -5037,12 +5117,13 @@ export class FlowSurfacesService { const layoutUid = this.normalizeExplicitDesktopRouteLayoutUid(document.navigation.layoutUid); const portalUid = this.navigationTargets.normalizePortalUid(document.navigation.portalUid); + const matchScope = await this.resolveDesktopRouteMatchScope(layoutUid, portalUid, 'applyBlueprint', options); const matchedRoutes = await this.findMenuGroupRoutesByParentIdAndTitle( null, groupTitle, - transaction, - layoutUid || (await this.resolveDefaultDesktopRouteUiLayoutUids(transaction)), - portalUid, + options.transaction, + matchScope.layoutUids, + matchScope.portalUid, ); if (!matchedRoutes.length) { return document; @@ -5071,6 +5152,34 @@ export class FlowSurfacesService { }; } + private async resolveApplyBlueprintDefaultNavigationTarget( + document: FlowSurfaceApplyBlueprintDocument, + options: { transaction?: Transaction; currentRoles?: FlowSurfaceRequestRoles } = {}, + ): Promise { + if ( + document.mode !== 'create' || + document.navigation?.layoutUid || + document.navigation?.portalUid || + !_.isUndefined(document.navigation?.group?.routeId) || + !this.navigationTargets.hasMultiPortalCapability() + ) { + return document; + } + + const portal = await this.navigationTargets.resolveDefaultPortal({ + actionName: 'applyBlueprint', + currentRoles: options.currentRoles, + transaction: options.transaction, + }); + return { + ...document, + navigation: { + ...document.navigation, + portalUid: portal.uid, + }, + }; + } + private async normalizeApplyBlueprintCreateMobileNavigation( document: FlowSurfaceApplyBlueprintDocument, options: { transaction?: any; currentRoles?: FlowSurfaceRequestRoles } = {}, @@ -5137,10 +5246,10 @@ export class FlowSurfacesService { return; } const groupRoute = await this.assertMenuParentIsGroup(groupRouteId, options.transaction); - await this.navigationTargets.assertRouteBelongsToPortal( + await this.assertRouteBelongsToResolvedPortal( 'applyBlueprint', - this.readRouteField(groupRoute, 'id'), - portal.uid, + groupRoute, + portal, 'values.navigation.group.routeId', options.transaction, ); @@ -5174,7 +5283,7 @@ export class FlowSurfacesService { private async resolveApplyBlueprintCreatePageIdentity( document: FlowSurfaceApplyBlueprintDocument, - transaction?: any, + options: { transaction?: Transaction; currentRoles?: FlowSurfaceRequestRoles } = {}, ): Promise { if (document.mode !== 'create') { return document; @@ -5188,11 +5297,11 @@ export class FlowSurfacesService { if (!pageTitle || (!hasGroupRouteId && !layoutUid && !portalUid)) { return document; } - const groupRoute = hasGroupRouteId ? await this.findMenuRouteById(groupRouteId, transaction) : null; + const groupRoute = hasGroupRouteId ? await this.findMenuRouteById(groupRouteId, options.transaction) : null; if (!portalUid && groupRoute) { const groupPortalUids = await this.navigationTargets.readRoutePortalUids( this.readRouteField(groupRoute, 'id'), - transaction, + options.transaction, ); if (groupPortalUids.length > 1) { throwBadRequest( @@ -5209,17 +5318,21 @@ export class FlowSurfacesService { } portalUid = groupPortalUids[0]; } - const layoutUidFilter = portalUid - ? undefined + const portalMatchScope = portalUid + ? await this.resolveDesktopRouteMatchScope(layoutUid, portalUid, 'applyBlueprint', options) + : undefined; + const layoutUidFilter = portalMatchScope + ? portalMatchScope.layoutUids : layoutUid || - (groupRoute ? await this.resolveInheritedDesktopRouteUiLayoutUids(groupRoute, transaction) : undefined); + (groupRoute ? await this.resolveInheritedDesktopRouteUiLayoutUids(groupRoute, options.transaction) : undefined); + const portalUidFilter = portalMatchScope?.portalUid; const matchedPages = await this.findFlowPageRoutesByParentIdAndTitle( hasGroupRouteId ? groupRouteId : null, pageTitle, - transaction, + options.transaction, layoutUidFilter, - portalUid, + portalUidFilter, ); if (!matchedPages.length) { return document; @@ -5262,14 +5375,15 @@ export class FlowSurfacesService { options: { transaction?: any; currentRoles?: FlowSurfaceRequestRoles } = {}, createdKanbanSortFields?: FlowSurfaceApplyBlueprintKanbanCreatedSortField[], ): Promise { - const initialDocument = prepareFlowSurfaceApplyBlueprintDocument(values); + const parsedDocument = prepareFlowSurfaceApplyBlueprintDocument(values); + const initialDocument = await this.resolveApplyBlueprintDefaultNavigationTarget(parsedDocument, options); await this.assertApplyBlueprintCreateNavigationTarget(initialDocument, options); const mobileNormalizedDocument = await this.normalizeApplyBlueprintCreateMobileNavigation(initialDocument, options); const groupResolvedDocument = await this.resolveApplyBlueprintCreateNavigationGroup( mobileNormalizedDocument, - options.transaction, + options, ); - const document = await this.resolveApplyBlueprintCreatePageIdentity(groupResolvedDocument, options.transaction); + const document = await this.resolveApplyBlueprintCreatePageIdentity(groupResolvedDocument, options); await this.prepareApplyBlueprintKanbanBlocks(document, options.transaction, createdKanbanSortFields); const replaceTarget = document.mode === 'replace' && document.target @@ -6080,8 +6194,18 @@ export class FlowSurfacesService { await assertFlowSurfaceAuthoringPayload('applyBlueprint', values, { transaction: options.transaction, enabledPackages, - findMenuGroupRoutesByTitle: (title, transaction, targetLayoutUid, targetPortalUid) => - this.findMenuGroupRoutesByTitle(title, transaction, targetLayoutUid, targetPortalUid), + findMenuGroupRoutesByTitle: async (title, transaction, targetLayoutUid, targetPortalUid) => { + const matchScope = await this.resolveDesktopRouteMatchScope( + targetLayoutUid, + targetPortalUid, + 'applyBlueprint', + { + transaction, + currentRoles: options.currentRoles, + }, + ); + return this.findMenuGroupRoutesByTitle(title, transaction, matchScope.layoutUids, matchScope.portalUid); + }, getUiLayoutTypeByUid: (layoutUid, transaction) => this.getDesktopRouteUiLayoutTypeByUid(layoutUid, transaction), getPortalLayoutTypeByUid: async (targetPortalUid, transaction) => { if (resolvedPortal?.uid === targetPortalUid) { @@ -8688,18 +8812,18 @@ export class FlowSurfacesService { currentRoles: options.currentRoles, transaction: options.transaction, }); - await this.navigationTargets.assertRouteBelongsToPortal( + await this.assertRouteBelongsToResolvedPortal( 'updateMenu', - this.readRouteField(route, 'id'), - portal.uid, + route, + portal, 'values.menuRouteId', options.transaction, ); if (nextParentRoute) { - await this.navigationTargets.assertRouteBelongsToPortal( + await this.assertRouteBelongsToResolvedPortal( 'updateMenu', - this.readRouteField(nextParentRoute, 'id'), - portal.uid, + nextParentRoute, + portal, 'values.parentMenuRouteId', options.transaction, ); diff --git a/packages/plugins/@nocobase/plugin-map/src/client-v2/__tests__/settingsLink.test.ts b/packages/plugins/@nocobase/plugin-map/src/client-v2/__tests__/settingsLink.test.ts new file mode 100644 index 00000000000..b61a54fd9ba --- /dev/null +++ b/packages/plugins/@nocobase/plugin-map/src/client-v2/__tests__/settingsLink.test.ts @@ -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. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { resolveMapSettingsHref } from '../settingsLink'; + +const originalModernPrefix = window.__nocobase_modern_client_prefix__; + +describe('resolveMapSettingsHref', () => { + afterEach(() => { + window.__nocobase_modern_client_prefix__ = originalModernPrefix; + }); + + it('opens the standalone Settings document for the main application', () => { + window.__nocobase_modern_client_prefix__ = 'v'; + const app = { + name: 'main', + getPublicPath: () => '/nocobase/v/', + pluginSettingsManager: { getRoutePath: () => '/admin/settings/map' }, + }; + + expect(resolveMapSettingsHref(app, '/nocobase/v/admin/demo')).toBe('/nocobase/settings/map'); + expect(resolveMapSettingsHref(app, '/nocobase/v/admin/demo', '?tab=google')).toBe( + '/nocobase/settings/map?tab=google', + ); + }); + + it.each(['apps', '_app'])('preserves the %s sub-application scope', (scope) => { + window.__nocobase_modern_client_prefix__ = 'modern'; + const app = { + name: 'demo', + getPublicPath: () => '/base/modern/', + pluginSettingsManager: { getRoutePath: () => '/admin/settings/map' }, + }; + + expect(resolveMapSettingsHref(app, `/base/modern/${scope}/demo/admin/page`)).toBe( + `/base/settings/${scope}/demo/map`, + ); + }); + + it.each(['apps', '_app'])( + 'ignores a %s segment in the root public path while preserving the runtime application scope', + (scope) => { + window.__nocobase_modern_client_prefix__ = 'modern'; + const publicPath = `/tenant/${scope}/root/modern/`; + const mainApp = { + name: 'main', + getPublicPath: () => publicPath, + pluginSettingsManager: { getRoutePath: () => '/admin/settings/map' }, + }; + const subApp = { + ...mainApp, + name: 'demo', + }; + + expect(resolveMapSettingsHref(mainApp, `${publicPath}admin/page`)).toBe(`/tenant/${scope}/root/settings/map`); + expect(resolveMapSettingsHref(subApp, `${publicPath}${scope}/demo/admin/page`)).toBe( + `/tenant/${scope}/root/settings/${scope}/demo/map`, + ); + }, + ); +}); diff --git a/packages/plugins/@nocobase/plugin-map/src/client-v2/models/components/AMap/Map.tsx b/packages/plugins/@nocobase/plugin-map/src/client-v2/models/components/AMap/Map.tsx index d1acd01848c..26d913c2c04 100644 --- a/packages/plugins/@nocobase/plugin-map/src/client-v2/models/components/AMap/Map.tsx +++ b/packages/plugins/@nocobase/plugin-map/src/client-v2/models/components/AMap/Map.tsx @@ -19,6 +19,7 @@ import { useT } from '../../../locale'; import { MapEditorType } from '../../../../shared/types'; import { normalizeErrorMessage, runIdleTask } from '../../../../shared/utils'; import { mapActiveColor } from '../../../../shared/theme'; +import { resolveMapSettingsHref } from '../../../settingsLink'; import { Search } from './Search'; export interface AMapComponentProps { value?: any; @@ -114,7 +115,6 @@ export const AMapCom = React.forwardRef(); const editor = useRef(null); - const { navigate } = ctx.router; const id = useRef(`nocobase-map-${type || ''}-${Date.now().toString(32)}`); const { modal } = App.useApp(); const [commonOptions] = useState({ @@ -434,9 +434,11 @@ export const AMapCom = React.forwardRef { ctx.view?.close?.(); - navigate('/admin/settings/map'); }} > {t('Go to the configuration page')} diff --git a/packages/plugins/@nocobase/plugin-map/src/client-v2/models/components/GoogleMaps/Map.tsx b/packages/plugins/@nocobase/plugin-map/src/client-v2/models/components/GoogleMaps/Map.tsx index 062b1be62f4..c410358515c 100644 --- a/packages/plugins/@nocobase/plugin-map/src/client-v2/models/components/GoogleMaps/Map.tsx +++ b/packages/plugins/@nocobase/plugin-map/src/client-v2/models/components/GoogleMaps/Map.tsx @@ -17,6 +17,7 @@ import { defaultImage } from '../../../../shared/constants'; import { mapActiveColor } from '../../../../shared/theme'; import { useMapConfig } from '../../../hooks'; import { useT } from '../../../locale'; +import { resolveMapSettingsHref } from '../../../settingsLink'; import { MapEditorType } from '../../../../shared/types'; import { Search } from './Search'; import { getCurrentPosition, getIcon } from './utils'; @@ -306,7 +307,6 @@ export const GoogleMapsCom = React.forwardRef(); const cleanupOverlayListenersRef = useRef void>>(new Set()); @@ -601,9 +601,11 @@ export const GoogleMapsCom = React.forwardRef { ctx.view?.close?.(); - navigate('/admin/settings/map' + '?tab=google'); }} > {t('Go to the configuration page')} diff --git a/packages/plugins/@nocobase/plugin-map/src/client-v2/settingsLink.ts b/packages/plugins/@nocobase/plugin-map/src/client-v2/settingsLink.ts new file mode 100644 index 00000000000..cc5b2374256 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-map/src/client-v2/settingsLink.ts @@ -0,0 +1,42 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { stripModernClientPrefix } from '@nocobase/client-v2'; + +type MapSettingsApp = { + name?: string; + getPublicPath: () => string; + pluginSettingsManager: { + getRoutePath: (name: string) => string; + }; +}; + +function getAppScope(publicPath: string, pathname: string, appName?: string) { + const root = `/${publicPath}`.replace(/\/{2,}/g, '/').replace(/\/+$/, ''); + const path = `/${pathname}`.replace(/\/{2,}/g, '/').split(/[?#]/)[0]; + const relativePath = root && (path === root || path.startsWith(`${root}/`)) ? path.slice(root.length) || '/' : path; + const pathScope = /^\/(?:apps|_app)\/[^/]+(?=\/|$)/.exec(relativePath)?.[0]; + if (pathScope) { + return pathScope; + } + return appName && appName !== 'main' ? `/apps/${appName}` : ''; +} + +export function resolveMapSettingsHref(app: MapSettingsApp, pathname: string, suffix = '') { + const publicPath = app.getPublicPath(); + const rootPublicPath = stripModernClientPrefix(publicPath).replace(/\/+$/, ''); + const appScope = getAppScope(publicPath, pathname, app.name); + const managerPath = app.pluginSettingsManager.getRoutePath('map'); + const settingsPath = managerPath.replace(/^\/admin\/settings(?=\/|$)/, '/settings'); + const scopedSettingsPath = settingsPath.replace(/^\/settings(?=\/|$)/, ''); + + return appScope + ? `${rootPublicPath}/settings${appScope}${scopedSettingsPath}${suffix}` + : `${rootPublicPath}${settingsPath}${suffix}`; +} diff --git a/packages/plugins/@nocobase/plugin-multi-portal/README.md b/packages/plugins/@nocobase/plugin-multi-portal/README.md index d7cac62d87d..582a9fb3947 100644 --- a/packages/plugins/@nocobase/plugin-multi-portal/README.md +++ b/packages/plugins/@nocobase/plugin-multi-portal/README.md @@ -1,12 +1,18 @@ # @nocobase/plugin-multi-portal -`@nocobase/plugin-multi-portal` is the commercial portal/permission layer built -on top of `@nocobase/plugin-ui-layout`. A portal selects an enabled UI Layout -and adds portal-scoped access and desktop route/menu permissions for roles. +`@nocobase/plugin-multi-portal` is the built-in Portal registration and +permission layer built on top of `@nocobase/plugin-ui-layout`. A Portal selects +an enabled UI Layout and provides a concrete application entry point. -UI Layout remains the open-source layout/route base. Multi-portal does not -replace the UI Layout registry; it composes with it to create isolated portal -entry points and permission boundaries. +For Client V2, enabled No-code Portals are the only source of registered Portal +routes. Fresh applications use Portal-scoped entry and route permissions. +Portals created while upgrading an existing application continue to use the +backing UI Layout's route ownership and role permissions, so Client V1 and +Client V2 share the same route tree without copying ACL data. + +UI Layout remains the layout and route-model base, and Client V1 keeps its +existing UI Layout registration behavior. AI Portals remain separate `/x` +entries and do not register No-code layouts in Client V2. The `authCheck` route option is not a complete public-access solution. Public portals or layouts must be implemented by plugin code that explicitly registers diff --git a/packages/plugins/@nocobase/plugin-multi-portal/package.json b/packages/plugins/@nocobase/plugin-multi-portal/package.json index f0ddba72926..31b2876153e 100644 --- a/packages/plugins/@nocobase/plugin-multi-portal/package.json +++ b/packages/plugins/@nocobase/plugin-multi-portal/package.json @@ -5,13 +5,12 @@ "nocobase": { "supportedVersions": [ "2.x" - ], - "editionLevel": 3 + ] }, "displayName": "Portal manager", "displayName.zh-CN": "Portal 管理", - "description": "Provides multi-portal management with separate layouts and menus for different entry points.", - "description.zh-CN": "提供多 Portal 管理能力,可为不同访问入口配置独立布局和菜单。", + "description": "Provides built-in Portal registration, entry access, and route permissions for Client V2.", + "description.zh-CN": "为 Client V2 提供内置 Portal 注册、入口访问与路由权限管理。", "devDependencies": { "@ant-design/icons": "5.x", "@nocobase/plugin-ui-layout": "2.2.0-alpha.11", diff --git a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/RootLanding.tsx b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/RootLanding.tsx new file mode 100644 index 00000000000..e682ce80fd2 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/RootLanding.tsx @@ -0,0 +1,114 @@ +/** + * 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 { useApp, type Application } from '@nocobase/client-v2'; +import { Flex, Result, Spin } from 'antd'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Navigate } from 'react-router-dom'; +import { DEFAULT_ADMIN_MULTI_PORTAL_UID } from '../constants'; +import { useT } from './locale'; +import { getMultiPortalRouteUrl } from './routeUrl'; + +export type RootLandingPortal = { + uid: string; + portalType?: string | null; + routePath: string; + uiLayout?: { + layoutType?: string | null; + }; +}; + +type RootLandingPortalListBody = { + data?: RootLandingPortal[]; +}; + +function getRootLandingPriority(portal: RootLandingPortal) { + if (portal.uid === DEFAULT_ADMIN_MULTI_PORTAL_UID && portal.portalType === 'no-code') { + return 0; + } + if (portal.portalType === 'no-code' && portal.uiLayout?.layoutType === 'desktop') { + return 1; + } + if (portal.portalType === 'no-code' && portal.uiLayout?.layoutType === 'mobile') { + return 2; + } + return 3; +} + +export function selectRootLandingPortal(portals: RootLandingPortal[]) { + return portals + .filter((portal) => portal.portalType === 'no-code' || portal.portalType === 'ai') + .map((portal, index) => ({ index, portal, priority: getRootLandingPriority(portal) })) + .sort((left, right) => left.priority - right.priority || left.index - right.index)[0]?.portal; +} + +export function RootLanding() { + const app = useApp(); + const t = useT(); + const [portals, setPortals] = useState(); + const [error, setError] = useState(); + const documentNavigationTargetRef = useRef(); + + useEffect(() => { + let active = true; + + const load = async () => { + try { + const response = await app.apiClient.request({ + url: 'multiPortals:listAccessible', + method: 'get', + skipNotify: true, + }); + if (active) { + setPortals(Array.isArray(response?.data?.data) ? response.data.data : []); + setError(undefined); + } + } catch (cause) { + if (active) { + setError(cause instanceof Error ? cause : new Error(String(cause))); + } + } + }; + + load(); + return () => { + active = false; + }; + }, [app]); + + const selectedPortal = useMemo(() => selectRootLandingPortal(portals ?? []), [portals]); + + useEffect(() => { + if (!selectedPortal || selectedPortal.portalType !== 'ai') { + return; + } + const target = getMultiPortalRouteUrl(app, selectedPortal.routePath, selectedPortal.portalType); + if (documentNavigationTargetRef.current === target) { + return; + } + documentNavigationTargetRef.current = target; + window.location.replace(target); + }, [app, selectedPortal]); + + if (error) { + return ; + } + if (!portals || (selectedPortal && selectedPortal.portalType === 'ai')) { + return ( + + + + ); + } + if (!selectedPortal) { + return ; + } + + return ; +} diff --git a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/MultiPortalsPage.test.tsx b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/MultiPortalsPage.test.tsx index 3c95670b6e3..01384b7669b 100644 --- a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/MultiPortalsPage.test.tsx +++ b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/MultiPortalsPage.test.tsx @@ -209,6 +209,7 @@ async function selectMobileLayout(container: HTMLElement, user: ReturnType { cleanup(); flowContext.current = undefined; + window.__nocobase_modern_client_prefix__ = undefined; }); describe('plugin-multi-portal settings page', () => { @@ -248,6 +249,63 @@ describe('plugin-multi-portal settings page', () => { ); }); + it.each([ + ['apps', 'no-code', '/customer-portal/dashboard', '/nocobase/v/apps/demo/customer-portal/dashboard'], + ['apps', 'ai', '/developer-portal', '/nocobase/x/apps/demo/developer-portal'], + ['_app', 'no-code', '/customer-portal/dashboard', '/nocobase/v/_app/demo/customer-portal/dashboard'], + ['_app', 'ai', '/developer-portal', '/nocobase/x/apps/demo/developer-portal'], + ])('should build %s Settings %s portal hrefs from the real runtime basename', (scope, portalType, path, expected) => { + const app = { + router: { + getBasename: () => `/nocobase/settings/${scope}/demo/`, + }, + getPublicPath: () => '/nocobase/', + }; + + expect(getMultiPortalRouteUrl(app, path, portalType)).toBe(expected); + }); + + it('should honor a custom modern client prefix in standalone Settings portal hrefs', () => { + window.__nocobase_modern_client_prefix__ = 'modern'; + const app = { + router: { + getBasename: () => '/nocobase/_app/demo/', + }, + getPublicPath: () => '/nocobase/', + }; + + expect(getMultiPortalRouteUrl(app, '/customer-portal/dashboard', 'no-code')).toBe( + '/nocobase/modern/_app/demo/customer-portal/dashboard', + ); + }); + + it.each(['apps', '_app'])( + 'should not treat a %s suffix in the main Settings public path as an application scope', + (scope) => { + window.__nocobase_modern_client_prefix__ = 'modern'; + const publicPath = `/tenant/${scope}/root/`; + const app = { + name: 'main', + router: { + getBasename: () => publicPath, + }, + getPublicPath: () => publicPath, + }; + const subApp = { + ...app, + name: 'demo', + router: { + getBasename: () => `${publicPath}settings/${scope}/demo/`, + }, + }; + + expect(getMultiPortalRouteUrl(app, '/admin', 'no-code')).toBe(`${publicPath}modern/admin`); + expect(getMultiPortalRouteUrl(app, '/assistant', 'ai')).toBe(`${publicPath}x/assistant`); + expect(getMultiPortalRouteUrl(subApp, '/admin', 'no-code')).toBe(`${publicPath}modern/${scope}/demo/admin`); + expect(getMultiPortalRouteUrl(subApp, '/assistant', 'ai')).toBe(`${publicPath}x/apps/demo/assistant`); + }, + ); + it('should keep portal wording user-facing translations consistent', () => { expect(enUS['Add portal']).toBe('Add portal'); expect(enUS['Edit portal']).toBe('Edit portal'); @@ -347,7 +405,9 @@ describe('plugin-multi-portal settings page', () => { { ...portalValues, uiLayout: { + layoutType: 'mobile', title: 'Mobile layout', + uid: 'mobile-layout-model', }, }, { @@ -360,6 +420,19 @@ describe('plugin-multi-portal settings page', () => { uiLayoutUid: null, uiLayout: null, }, + { + ...portalValues, + title: 'Disabled portal', + uid: 'disabled-portal', + portalName: 'disabled-portal', + routePath: '/disabled-portal', + enabled: false, + uiLayout: { + layoutType: 'desktop', + title: 'Desktop layout', + uid: 'desktop-layout-model', + }, + }, ], }, }), @@ -412,10 +485,19 @@ describe('plugin-multi-portal settings page', () => { expect(screen.getByText('Access path')).toBeInTheDocument(); expect(screen.getByText('Layout')).toBeInTheDocument(); expect(screen.getByText('Enabled')).toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: /mode/i })).not.toBeInTheDocument(); expect(screen.getAllByRole('link', { name: /View/ })[0]).toHaveAttribute('href', '/v/customer-portal'); - const actionCell = container.querySelector('tbody tr .ant-table-cell:last-child'); + const customerPortalRow = screen.getByText('Customer portal').closest('tr') as HTMLElement; + const developerPortalRow = screen.getByText('Developer portal').closest('tr') as HTMLElement; + const disabledPortalRow = screen.getByText('Disabled portal').closest('tr') as HTMLElement; + const routesButton = within(customerPortalRow).getByRole('button', { name: 'Routes' }); + expect(routesButton).toBeEnabled(); + expect(within(developerPortalRow).queryByRole('button', { name: 'Routes' })).not.toBeInTheDocument(); + expect(within(disabledPortalRow).getByRole('button', { name: 'Routes' })).toBeDisabled(); + + const actionCell = customerPortalRow.querySelector('.ant-table-cell:last-child'); const actionButtons = actionCell?.querySelectorAll('.ant-btn-link') ?? []; - expect(actionButtons).toHaveLength(3); + expect(Array.from(actionButtons).map((button) => button.textContent)).toEqual(['View', 'Edit', 'Routes', 'Delete']); actionButtons.forEach((button) => { expect(button).toHaveStyle('padding-inline: 0'); }); @@ -424,6 +506,14 @@ describe('plugin-multi-portal settings page', () => { 'ant-btn-dangerous', ); expect(screen.queryByRole('button', { name: /Logs/ })).not.toBeInTheDocument(); + await user.click(routesButton); + expect(flowContext.current?.viewer.drawer).toHaveBeenLastCalledWith( + expect.objectContaining({ + closable: true, + content: expect.any(Function), + width: '80%', + }), + ); await user.click(within(actionCell as HTMLElement).getByRole('button', { name: /Delete/ })); expect(await screen.findByText('Are you sure you want to delete it?')).toBeInTheDocument(); expect(screen.getByText('The corresponding portal directory will also be deleted.')).toBeInTheDocument(); @@ -1291,7 +1381,7 @@ describe('plugin-multi-portal settings page', () => { }); }); - it('should allow toggling enabled for default portals from the table', async () => { + it('should treat the legacy default uid as a normal portal in the table', async () => { const user = userEvent.setup(); const resource = makeResource({ list: vi.fn().mockResolvedValue({ @@ -1354,7 +1444,7 @@ describe('plugin-multi-portal settings page', () => { }); }); - it('should allow toggling enabled for default portals from the edit form', async () => { + it('should not lock editable fields for the legacy default uid but should keep its layout immutable', async () => { const user = userEvent.setup(); let drawerContent: React.ReactNode; const resource = makeResource({ @@ -1417,8 +1507,10 @@ describe('plugin-multi-portal settings page', () => { ); const dialog = await screen.findByRole('dialog', { name: 'Edit portal' }); - expect(within(dialog).getByLabelText('Portal name')).toBeDisabled(); + expect(within(dialog).getByLabelText('Portal name')).not.toBeDisabled(); + expect(within(dialog).getByLabelText('Portal type')).not.toBeDisabled(); expect(within(dialog).getByLabelText('Enabled')).not.toBeDisabled(); + expect(within(dialog).getByLabelText('Layout')).toBeDisabled(); }); it('should populate the layout field from the appended uiLayout relation when editing', async () => { diff --git a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/PortalRoutesDrawer.test.tsx b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/PortalRoutesDrawer.test.tsx new file mode 100644 index 00000000000..c2afbcd8f42 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/PortalRoutesDrawer.test.tsx @@ -0,0 +1,482 @@ +/** + * 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 { App as AntdApp } from 'antd'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { MultiPortalRecord } from '../pages/MultiPortalsPage'; +import PortalRoutesDrawer from '../pages/PortalRoutesDrawer'; + +const flowContext = vi.hoisted(() => ({ + current: undefined as + | { + api: { + request: ReturnType; + resource: ReturnType; + }; + app: { + router: { + getBasename: () => string; + }; + }; + viewer: { + drawer: ReturnType; + }; + routeRepository: { + refreshAccessible: ReturnType; + }; + } + | undefined, +})); + +vi.mock('@nocobase/client-v2', async (importOriginal) => { + const actual = await importOriginal(); + const ReactModule = await import('react'); + return { + ...actual, + IconPicker: (props: { onChange?: (value: string) => void; value?: string }) => + ReactModule.createElement( + 'button', + { + 'aria-label': props.value ? `Selected icon ${props.value}` : 'Select icon', + onClick: () => props.onChange?.('AppstoreOutlined'), + type: 'button', + }, + props.value || 'Select icon', + ), + }; +}); + +vi.mock('@nocobase/flow-engine', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + randomId: () => 'random-id', + useFlowContext: () => flowContext.current, + useFlowEngine: () => ({ + context: { + t: (key: string, options?: Record) => + key.replace(/\{\{(\w+)\}\}/g, (_, name) => String(options?.[name] ?? '')), + }, + }), + useFlowView: () => ({ + close: vi.fn(), + Footer: ({ children }: { children?: React.ReactNode }) =>
{children}
, + Header: ({ title }: { title?: React.ReactNode }) =>

{title}

, + }), + }; +}); + +const desktopRoutesResource = { + create: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), +}; + +function renderPortalRoutes( + portal: MultiPortalRecord, + routes: Array> = [ + { + id: 1, + schemaUid: 'dashboard', + title: 'Dashboard', + type: 'flowPage', + }, + ], +) { + const request = vi.fn().mockResolvedValue({ + data: { + data: routes, + }, + }); + const drawer = vi.fn(); + const refreshAccessible = vi.fn().mockResolvedValue(undefined); + flowContext.current = { + api: { + request, + resource: vi.fn((name: string) => { + if (name === 'desktopRoutes') { + return desktopRoutesResource; + } + throw new Error(`Unexpected resource: ${name}`); + }), + }, + app: { + router: { + getBasename: () => '/v', + }, + }, + viewer: { + drawer, + }, + routeRepository: { + refreshAccessible, + }, + }; + + render( + + + , + ); + return { drawer, refreshAccessible, request }; +} + +function renderLatestDrawer(drawer: ReturnType) { + const options = drawer.mock.calls.at(-1)?.[0] as + | { + content?: () => React.ReactNode; + } + | undefined; + if (!options?.content) { + throw new Error('Expected a Flow Viewer drawer content renderer'); + } + return render({options.content()}); +} + +async function selectFirstIcon(container: HTMLElement) { + fireEvent.click(within(container).getByRole('button', { name: 'Select icon' })); + await waitFor(() => { + expect(within(container).getByRole('button', { name: 'Selected icon AppstoreOutlined' })).toBeInTheDocument(); + }); +} + +async function confirmRouteDelete(title: 'Delete route' | 'Delete routes') { + const deleteTitle = await screen.findByText(title); + const dialog = deleteTitle.closest('.ant-modal-confirm') as HTMLElement | null; + expect(dialog).toBeTruthy(); + fireEvent.click(within(dialog as HTMLElement).getByRole('button', { name: 'Delete' })); + await waitFor(() => { + expect(document.body.querySelector('.ant-modal-confirm')).not.toBeInTheDocument(); + }); +} + +afterEach(() => { + cleanup(); + flowContext.current = undefined; + vi.clearAllMocks(); +}); + +describe('PortalRoutesDrawer', () => { + it('loads one custom portal route tree with only the portal identity', async () => { + const user = userEvent.setup(); + const { drawer, request } = renderPortalRoutes({ + title: 'Customer portal', + uid: 'customer-portal', + portalType: 'no-code', + portalName: 'customer-portal', + routePath: '/customer-portal', + uiLayoutUid: 'mobile-layout-model', + enabled: true, + uiLayout: { + layoutType: 'mobile', + title: 'Mobile layout', + uid: 'mobile-layout-model', + }, + }); + + expect(await screen.findByRole('heading', { name: 'Routes' })).toBeInTheDocument(); + expect(await screen.findByText('Dashboard')).toBeInTheDocument(); + expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); + await waitFor(() => { + expect(request).toHaveBeenCalledWith({ + method: 'get', + params: { + paginate: false, + portal: 'customer-portal', + sort: 'sort', + tree: true, + }, + skipNotify: true, + url: '/desktopRoutes:list', + }); + }); + + const rowCheckbox = screen.getAllByRole('checkbox')[1]; + await user.click(rowCheckbox); + await user.click(screen.getByRole('button', { name: 'Hide in menu' })); + await waitFor(() => { + expect(desktopRoutesResource.update).toHaveBeenCalledWith({ + filterByTk: 1, + portal: 'customer-portal', + values: { + hideInMenu: true, + }, + }); + }); + + await user.click(screen.getByRole('button', { name: 'Add new' })); + expect(drawer).toHaveBeenCalledWith( + expect.objectContaining({ + closable: true, + content: expect.any(Function), + width: expect.any(Number), + }), + ); + }); + + it('does not special-case the legacy default portal uid or send a layout owner', async () => { + const { request } = renderPortalRoutes({ + title: 'Admin', + uid: '__default_portal__', + portalType: 'no-code', + portalName: 'admin', + routePath: '/admin', + uiLayoutUid: 'admin-layout-model', + enabled: true, + uiLayout: { + layoutType: 'desktop', + title: 'Desktop layout', + uid: 'admin-layout-model', + }, + }); + + expect(await screen.findByText('Dashboard')).toBeInTheDocument(); + await waitFor(() => { + expect(request).toHaveBeenCalledWith({ + method: 'get', + params: { + paginate: false, + portal: '__default_portal__', + sort: 'sort', + tree: true, + }, + skipNotify: true, + url: '/desktopRoutes:list', + }); + }); + const listRequest = request.mock.calls.find(([options]) => options.url === '/desktopRoutes:list')?.[0]; + expect(listRequest?.params).not.toHaveProperty('filter'); + expect(listRequest?.params).not.toHaveProperty('layout'); + }); + + it('uses the custom portal scope without refreshing the Settings global route repository', async () => { + const { drawer, refreshAccessible } = renderPortalRoutes( + { + title: 'Customer portal', + uid: 'customer-portal', + portalType: 'no-code', + portalName: 'customer-portal', + routePath: '/customer-portal', + uiLayoutUid: 'desktop-layout-model', + enabled: true, + uiLayout: { + layoutType: 'desktop', + title: 'Desktop layout', + uid: 'desktop-layout-model', + }, + }, + [ + { + id: 1, + enableTabs: true, + schemaUid: 'dashboard', + title: 'Dashboard', + type: 'flowPage', + }, + ], + ); + + const dashboardRow = await screen.findByRole('row', { name: /Dashboard/ }); + expect(within(dashboardRow).getByRole('link', { name: 'View Dashboard' })).toHaveAttribute( + 'href', + '/v/customer-portal/dashboard', + ); + + fireEvent.click(screen.getByRole('button', { name: 'Add new' })); + const addEditor = renderLatestDrawer(drawer); + fireEvent.change(addEditor.getByLabelText('Title'), { target: { value: 'Reports' } }); + fireEvent.click(addEditor.getByRole('button', { name: 'Submit' })); + await waitFor(() => { + expect(desktopRoutesResource.create).toHaveBeenCalledWith({ + portal: 'customer-portal', + values: expect.objectContaining({ + children: [ + expect.objectContaining({ + hidden: true, + type: 'tabs', + }), + ], + title: 'Reports', + type: 'flowPage', + }), + }); + }); + addEditor.unmount(); + + fireEvent.click(within(dashboardRow).getByRole('button', { name: 'Edit Dashboard' })); + const editEditor = renderLatestDrawer(drawer); + fireEvent.change(editEditor.getByLabelText('Title'), { target: { value: 'Customer dashboard' } }); + fireEvent.click(editEditor.getByRole('button', { name: 'Submit' })); + await waitFor(() => { + expect(desktopRoutesResource.update).toHaveBeenCalledWith({ + filterByTk: 1, + portal: 'customer-portal', + values: expect.objectContaining({ + title: 'Customer dashboard', + }), + }); + }); + editEditor.unmount(); + + fireEvent.click(within(dashboardRow).getByRole('button', { name: 'Delete Dashboard' })); + await confirmRouteDelete('Delete route'); + await waitFor(() => { + expect(desktopRoutesResource.destroy).toHaveBeenCalledWith({ + filterByTk: 1, + portal: 'customer-portal', + }); + }); + expect(refreshAccessible).not.toHaveBeenCalled(); + }); + + it('uses mobile route rules and persists mobile links with the portal scope', async () => { + const { drawer } = renderPortalRoutes({ + title: 'Mobile portal', + uid: 'mobile-portal', + portalType: 'no-code', + portalName: 'mobile-portal', + routePath: '/mobile-portal', + uiLayoutUid: 'mobile-layout-model', + enabled: true, + uiLayout: { + layoutType: 'mobile', + title: 'Mobile layout', + uid: 'mobile-layout-model', + }, + }); + + expect(await screen.findByText('Dashboard')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Add new' })); + const editor = renderLatestDrawer(drawer); + expect(editor.queryByRole('radio', { name: 'Group' })).not.toBeInTheDocument(); + expect(editor.getByRole('radio', { name: 'Page' })).toBeChecked(); + fireEvent.click(editor.getByRole('radio', { name: 'Link' })); + fireEvent.change(editor.getByLabelText('Title'), { target: { value: 'Mobile docs' } }); + fireEvent.change(editor.getByLabelText('URL'), { target: { value: '/docs' } }); + fireEvent.click(editor.getByRole('button', { name: 'Add parameter' })); + fireEvent.change(editor.getByPlaceholderText('Name'), { target: { value: 'from' } }); + fireEvent.change(editor.getByPlaceholderText('Value'), { target: { value: 'portal' } }); + const iconFormItem = editor.getByText('Icon').closest('.ant-form-item'); + expect(iconFormItem?.querySelector('label')).toHaveClass('ant-form-item-required'); + + await selectFirstIcon(editor.container); + fireEvent.click(editor.getByRole('button', { name: 'Submit' })); + await waitFor(() => { + expect(desktopRoutesResource.create).toHaveBeenCalledWith({ + portal: 'mobile-portal', + values: expect.objectContaining({ + options: { + params: [{ name: 'from', value: 'portal' }], + url: '/docs', + }, + title: 'Mobile docs', + type: 'link', + }), + }); + }); + }); + + it('uses the legacy uid as a normal portal identity without a global route refresh', async () => { + const { refreshAccessible, request } = renderPortalRoutes( + { + title: 'Admin', + uid: '__default_portal__', + portalType: 'no-code', + portalName: 'admin', + routePath: '/admin', + uiLayoutUid: 'admin-layout-model', + enabled: true, + uiLayout: { + layoutType: 'desktop', + title: 'Desktop layout', + uid: 'admin-layout-model', + }, + }, + [ + { + id: 1, + enableTabs: true, + schemaUid: 'dashboard', + title: 'Dashboard', + type: 'flowPage', + children: [ + { + id: 2, + parentId: 1, + schemaUid: 'overview', + title: 'Overview', + type: 'tabs', + }, + { + id: 3, + hidden: true, + parentId: 1, + schemaUid: 'hidden-tab', + title: 'Hidden tab', + type: 'tabs', + }, + ], + }, + { + id: 4, + title: 'Navigation group', + type: 'group', + }, + { + id: 5, + schemaUid: 'legacy-page', + title: 'Legacy v1 page', + type: 'page', + }, + ], + ); + + const dashboardRow = await screen.findByRole('row', { name: /Dashboard/ }); + await waitFor(() => { + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + portal: '__default_portal__', + }), + }), + ); + }); + expect(screen.queryByText('Legacy v1 page')).not.toBeInTheDocument(); + expect(screen.queryByText('Hidden tab')).not.toBeInTheDocument(); + expect(within(dashboardRow).getByRole('link', { name: 'View Dashboard' })).toHaveAttribute( + 'href', + '/v/admin/dashboard', + ); + + fireEvent.click(within(dashboardRow).getByRole('checkbox')); + fireEvent.click(screen.getByRole('button', { name: 'Hide in menu' })); + await waitFor(() => { + expect(desktopRoutesResource.update).toHaveBeenCalledWith({ + filterByTk: 1, + portal: '__default_portal__', + values: { hideInMenu: true }, + }); + expect(refreshAccessible).not.toHaveBeenCalled(); + }); + + const groupRow = screen.getByRole('row', { name: /Navigation group/ }); + fireEvent.click(within(groupRow).getByRole('button', { name: 'Delete Navigation group' })); + await confirmRouteDelete('Delete route'); + await waitFor(() => { + expect(desktopRoutesResource.destroy).toHaveBeenCalledWith({ + filterByTk: 4, + portal: '__default_portal__', + }); + }); + expect(refreshAccessible).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/RootLanding.test.ts b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/RootLanding.test.ts new file mode 100644 index 00000000000..e3434e565b2 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/RootLanding.test.ts @@ -0,0 +1,88 @@ +/** + * 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 { selectRootLandingPortal } from '../RootLanding'; + +describe('Client V2 portal root landing', () => { + const aiPortal = { + uid: 'ai-workspace', + routePath: '/assistant', + portalType: 'ai', + uiLayout: { + layoutType: 'desktop', + }, + }; + const mobilePortal = { + uid: 'mobile-workspace', + routePath: '/mobile-workspace', + portalType: 'no-code', + uiLayout: { + layoutType: 'mobile', + }, + }; + const desktopPortal = { + uid: 'customer-workspace', + routePath: '/customer-workspace', + portalType: 'no-code', + uiLayout: { + layoutType: 'desktop', + }, + }; + const adminPortal = { + uid: '__default_admin__', + routePath: '/admin', + portalType: 'no-code', + uiLayout: { + layoutType: 'desktop', + }, + }; + + it('prefers the canonical Admin portal, then desktop, mobile, and AI', () => { + expect(selectRootLandingPortal([aiPortal, mobilePortal, desktopPortal, adminPortal])).toEqual(adminPortal); + expect(selectRootLandingPortal([aiPortal, mobilePortal, desktopPortal])).toEqual(desktopPortal); + expect(selectRootLandingPortal([aiPortal, mobilePortal])).toEqual(mobilePortal); + expect(selectRootLandingPortal([aiPortal])).toEqual(aiPortal); + expect(selectRootLandingPortal([])).toBeUndefined(); + }); + + it('does not give similar UIDs the fixed Admin priority', () => { + const similarUidPortal = { + uid: 'admin-layout-model', + routePath: '/similar-admin', + portalType: 'no-code', + uiLayout: { + layoutType: 'mobile', + }, + }; + + expect(selectRootLandingPortal([similarUidPortal, desktopPortal])).toEqual(desktopPortal); + }); + + it('ignores portals with a missing or unknown portal type', () => { + const missingTypePortal = { + uid: 'admin-layout-model', + routePath: '/unregistered-admin', + portalType: null, + uiLayout: { + layoutType: 'desktop', + }, + }; + const unknownTypePortal = { + uid: 'unknown-workspace', + routePath: '/unknown-workspace', + portalType: 'unknown', + uiLayout: { + layoutType: 'desktop', + }, + }; + + expect(selectRootLandingPortal([missingTypePortal, unknownTypePortal, aiPortal])).toEqual(aiPortal); + expect(selectRootLandingPortal([missingTypePortal, unknownTypePortal])).toBeUndefined(); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/desktopPortalRouteIdentity.test.ts b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/desktopPortalRouteIdentity.test.ts new file mode 100644 index 00000000000..f2f17f29f0a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/desktopPortalRouteIdentity.test.ts @@ -0,0 +1,234 @@ +/** + * 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 { RouteRepository } from '@nocobase/client-v2'; +import { describe, expect, it, vi } from 'vitest'; +import { + getMultiPortalRouteScopeCacheKey, + installMultiPortalRouteRepositoryScope, + type MultiPortalRouteScopeDescriptor, +} from '../routeRepositoryScope'; + +type RequestOptions = { + action?: string; + data?: Record; + method?: string; + params?: Record; + resource?: string; + url?: string; +}; + +function createRouteRuntime() { + const request = vi.fn(async (_options: RequestOptions) => ({ data: { data: {} } })); + const api = { + request, + resource: vi.fn(() => ({})), + }; + const repository = new RouteRepository({ api } as never); + + return { api, repository, request }; +} + +function createPortalScope(portalUid: string): MultiPortalRouteScopeDescriptor { + return { + cacheKey: getMultiPortalRouteScopeCacheKey(portalUid), + portalUid, + }; +} + +describe('desktop portal route identity', () => { + it('treats a Portal UID containing the cache prefix as an opaque identity', async () => { + const { api, repository, request } = createRouteRuntime(); + installMultiPortalRouteRepositoryScope(repository, () => [createPortalScope('portal:customer')]); + const deactivatePortal = repository.activateLayout({ uid: 'portal:customer' }); + + await api.request({ + method: 'get', + url: '/desktopRoutes:listAccessible', + }); + + deactivatePortal(); + + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/desktopRoutes:listAccessible', + params: { + portal: 'portal:customer', + }, + }); + }); + + it('does not interpret an ordinary Layout UID as a Portal cache key', async () => { + const { api, repository, request } = createRouteRuntime(); + installMultiPortalRouteRepositoryScope(repository, () => [createPortalScope('customer')]); + const deactivateLayout = repository.activateLayout({ uid: 'portal:customer' }); + + await api.request({ + method: 'get', + url: '/desktopRoutes:listAccessible', + params: { + layout: 'portal:customer', + }, + }); + + deactivateLayout(); + + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/desktopRoutes:listAccessible', + params: { + layout: 'portal:customer', + }, + }); + }); + + it('isolates an ordinary Layout whose UID equals a Portal cache key', () => { + const { repository } = createRouteRuntime(); + installMultiPortalRouteRepositoryScope(repository, () => [createPortalScope('customer')]); + + const deactivatePortal = repository.activateLayout({ uid: 'customer' }); + repository.setRoutes([{ schemaUid: 'portal-page' }]); + deactivatePortal(); + + const deactivateLayout = repository.activateLayout({ uid: 'portal:customer' }); + repository.setRoutes([{ schemaUid: 'layout-page' }]); + expect(repository.listAccessible().map((route) => route.schemaUid)).toEqual(['layout-page']); + deactivateLayout(); + + const reactivatePortal = repository.activateLayout({ uid: 'customer' }); + expect(repository.listAccessible().map((route) => route.schemaUid)).toEqual(['portal-page']); + reactivatePortal(); + }); + + it('replaces an inherited Mobile layout scope with the active Portal identity', async () => { + const { api, repository, request } = createRouteRuntime(); + installMultiPortalRouteRepositoryScope(repository, () => [createPortalScope('__default_mobile__')]); + const deactivatePortal = repository.activateLayout({ uid: '__default_mobile__' }); + + await api.request({ + method: 'get', + url: '/desktopRoutes:listAccessible', + params: { + tree: true, + sort: 'sort', + layout: 'mobile-layout-model', + portal: 'forged-portal', + }, + }); + + deactivatePortal(); + + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/desktopRoutes:listAccessible', + params: { + tree: true, + sort: 'sort', + portal: '__default_mobile__', + }, + }); + }); + + it('attaches the active Portal UID to direct PageModel desktop route mutations', async () => { + const { api, repository, request } = createRouteRuntime(); + installMultiPortalRouteRepositoryScope(repository, () => [createPortalScope('customer-portal')]); + const deactivatePortal = repository.activateLayout({ uid: 'customer-portal' }); + + await api.request({ + method: 'post', + url: 'desktopRoutes:update?filter[id]=11', + data: { enableTabs: true }, + }); + await api.request({ + method: 'post', + url: 'desktopRoutes:updateOrCreate', + params: { filterKeys: ['schemaUid'] }, + data: { schemaUid: 'tab-1' }, + }); + await api.request({ + method: 'post', + url: 'desktopRoutes:destroy', + params: { filter: { schemaUid: 'tab-1' }, portal: 'forged-portal' }, + }); + await api.request({ + method: 'post', + url: '/desktopRoutes:move', + params: { sourceId: 11, targetId: 12, sortField: 'sort' }, + }); + await api.request({ + resource: 'users', + action: 'list', + params: { pageSize: 20 }, + }); + + deactivatePortal(); + + expect(request).toHaveBeenNthCalledWith(1, { + method: 'post', + url: 'desktopRoutes:update?filter[id]=11', + params: { portal: 'customer-portal' }, + data: { enableTabs: true }, + }); + expect(request).toHaveBeenNthCalledWith(2, { + method: 'post', + url: 'desktopRoutes:updateOrCreate', + params: { filterKeys: ['schemaUid'], portal: 'customer-portal' }, + data: { schemaUid: 'tab-1' }, + }); + expect(request).toHaveBeenNthCalledWith(3, { + method: 'post', + url: 'desktopRoutes:destroy', + params: { filter: { schemaUid: 'tab-1' }, portal: 'customer-portal' }, + }); + expect(request).toHaveBeenNthCalledWith(4, { + method: 'post', + url: '/desktopRoutes:move', + params: { sourceId: 11, targetId: 12, sortField: 'sort', portal: 'customer-portal' }, + }); + expect(request).toHaveBeenNthCalledWith(5, { + resource: 'users', + action: 'list', + params: { pageSize: 20 }, + }); + }); + + it('keeps non-Portal and uninstalled Settings API requests unchanged', async () => { + const { api, repository, request } = createRouteRuntime(); + installMultiPortalRouteRepositoryScope(repository, () => [createPortalScope('customer-portal')]); + const deactivateLayout = repository.activateLayout({ uid: 'standalone-layout' }); + + await api.request({ + method: 'post', + url: 'desktopRoutes:update?filter[id]=11', + params: { source: 'layout' }, + data: { enableTabs: true }, + }); + deactivateLayout(); + + expect(request).toHaveBeenCalledWith({ + method: 'post', + url: 'desktopRoutes:update?filter[id]=11', + params: { source: 'layout' }, + data: { enableTabs: true }, + }); + + const settingsRequest = vi.fn(async (_options: RequestOptions) => ({ data: { data: {} } })); + const settingsApi = { request: settingsRequest }; + await settingsApi.request({ + method: 'post', + url: 'desktopRoutes:update?filter[id]=11', + params: { source: 'settings' }, + }); + expect(settingsRequest).toHaveBeenCalledWith({ + method: 'post', + url: 'desktopRoutes:update?filter[id]=11', + params: { source: 'settings' }, + }); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/layoutRegistration.strict.test.ts b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/layoutRegistration.strict.test.ts new file mode 100644 index 00000000000..d8548a48e67 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/layoutRegistration.strict.test.ts @@ -0,0 +1,133 @@ +/** + * 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 { + registerMultiPortalRecords, + registerMultiPortalsFromApi, + type MultiPortalRuntimeRecord, +} from '../layoutRegistration'; + +const routeScopeMocks = vi.hoisted(() => ({ + installMultiPortalRouteRepositoryScope: vi.fn(), +})); + +vi.mock('../routeRepositoryScope', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + installMultiPortalRouteRepositoryScope: routeScopeMocks.installMultiPortalRouteRepositoryScope, + }; +}); + +const portal = { + uid: 'customer-portal', + title: 'Customer', + portalType: 'no-code', + portalName: 'customer', + routePath: '/customer', + authCheck: true, + enabled: true, + uiLayout: { + layoutType: 'desktop', + }, +} satisfies MultiPortalRuntimeRecord; + +function createLayoutManager() { + return { + hasLayout: vi.fn(() => false), + listLayouts: vi.fn((): Array<{ routeName: string; uid: string }> => []), + registerLayout: vi.fn(), + }; +} + +describe('Multi Portal runtime registration failures', () => { + beforeEach(() => { + routeScopeMocks.installMultiPortalRouteRepositoryScope.mockClear(); + }); + + it('propagates a missing or failed listEnabled endpoint', async () => { + const error = new Error('endpoint missing'); + await expect( + registerMultiPortalsFromApi({ + apiClient: { + request: vi.fn().mockRejectedValue(error), + }, + layoutManager: createLayoutManager(), + }), + ).rejects.toBe(error); + }); + + it('rejects unknown layout type, duplicate uid, and duplicate route name before registration', () => { + expect(() => + registerMultiPortalRecords(createLayoutManager(), [ + { + ...portal, + uiLayout: { + layoutType: 'unknown', + }, + }, + ]), + ).toThrow("Portal 'customer-portal' uses an unknown UI layout type 'unknown'."); + expect(() => + registerMultiPortalRecords(createLayoutManager(), [portal, { ...portal, portalName: 'customer-copy' }]), + ).toThrow("Duplicate portal uid 'customer-portal'."); + expect(() => + registerMultiPortalRecords(createLayoutManager(), [portal, { ...portal, uid: 'customer-copy' }]), + ).toThrow("Duplicate portal route name 'customer'."); + + const layoutManager = createLayoutManager(); + layoutManager.listLayouts.mockReturnValue([{ routeName: 'existing', uid: portal.uid }]); + expect(() => registerMultiPortalRecords(layoutManager, [portal])).toThrow( + "Duplicate portal uid 'customer-portal'.", + ); + expect(layoutManager.registerLayout).not.toHaveBeenCalled(); + }); + + it('registers only no-code portals and skips every other portal type', () => { + const layoutManager = createLayoutManager(); + const skippedPortals = [ + { ...portal, uid: 'ai-portal', portalName: 'ai', portalType: 'ai' }, + { ...portal, uid: 'missing-type-portal', portalName: 'missingType', portalType: undefined }, + { ...portal, uid: 'empty-type-portal', portalName: 'emptyType', portalType: '' }, + { ...portal, uid: 'unknown-type-portal', portalName: 'unknownType', portalType: 'custom' }, + ]; + + expect(registerMultiPortalRecords(layoutManager, skippedPortals)).toEqual([]); + expect(layoutManager.registerLayout).not.toHaveBeenCalled(); + }); + + it('keeps backing Layout identity out of the Client Portal scope descriptor', async () => { + const layoutPortal = { + ...portal, + uid: '__default_admin__', + portalName: 'admin', + routePath: '/admin', + } satisfies MultiPortalRuntimeRecord; + + await registerMultiPortalsFromApi({ + apiClient: { + request: vi.fn().mockResolvedValue({ data: { data: [layoutPortal] } }), + }, + context: { + routeRepository: {}, + }, + layoutManager: createLayoutManager(), + }); + + const getScopes = routeScopeMocks.installMultiPortalRouteRepositoryScope.mock.calls[0]?.[1] as + | (() => unknown) + | undefined; + expect(getScopes?.()).toEqual([ + { + cacheKey: 'portal:__default_admin__', + portalUid: '__default_admin__', + }, + ]); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/multiPortalPermissions.test.tsx b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/multiPortalPermissions.test.tsx index d9e982865f5..15df972309b 100644 --- a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/multiPortalPermissions.test.tsx +++ b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/multiPortalPermissions.test.tsx @@ -113,6 +113,173 @@ describe('plugin-multi-portal route permissions', () => { expect(resource.messageSuccess).toHaveBeenCalledWith('Saved successfully'); }); + it('should use layout route permissions for the fixed Admin Portal', async () => { + const resource = createMultiPortalPermissionResources({ + portals: [ + { + uid: '__default_admin__', + title: 'Desktop portal', + portalType: 'no-code', + }, + ], + selectedPortalUids: [], + selectedRouteIds: [], + }); + const user = userEvent.setup(); + const onRoleChange = vi.fn(); + flowMocks.context = resource.context; + + render( + + + , + ); + + expect(await screen.findByText('Managed by layout permissions')).toBeInTheDocument(); + expect(screen.queryByRole('checkbox', { name: 'Allow access to Desktop portal' })).not.toBeInTheDocument(); + + await act(async () => { + await user.click(screen.getByRole('button', { name: 'Configure routes permissions for Desktop portal' })); + }); + + const drawer = await screen.findByRole('dialog', { + name: 'Configure routes permissions for Desktop portal', + }); + const reportsRoute = within(drawer).getByRole('checkbox', { name: 'Allow access to Reports' }); + const allowNewRoutes = within(drawer).getByRole('checkbox', { + name: 'New routes are allowed to be accessed by default', + }); + + await waitFor(() => { + expect(resource.layoutRoutePermissionList).toHaveBeenCalledWith({ + paginate: false, + filter: { + id: [1, 2], + }, + }); + }); + expect(resource.routePermissionList).not.toHaveBeenCalled(); + expect(resource.routeDefaultPolicyList).not.toHaveBeenCalled(); + + await act(async () => { + await user.click(reportsRoute); + }); + await waitFor(() => { + expect(resource.layoutRoutePermissionAdd).toHaveBeenCalledWith({ values: [2] }); + }); + expect(resource.routePermissionCreate).not.toHaveBeenCalled(); + + await act(async () => { + await user.click(allowNewRoutes); + }); + await waitFor(() => { + expect(resource.rolesUpdate).toHaveBeenCalledWith({ + filterByTk: 'portal-member', + values: { + allowNewMenu: true, + }, + }); + }); + expect(onRoleChange).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'portal-member', + allowNewMenu: true, + }), + ); + expect(resource.rolePortalAdd).not.toHaveBeenCalled(); + expect(resource.rolePortalRemove).not.toHaveBeenCalled(); + }); + + it('should only expose entry access permission for an AI portal', async () => { + const resource = createMultiPortalPermissionResources({ + portals: [ + { + uid: 'ai-workspace', + title: 'AI workspace', + portalType: 'ai', + }, + ], + selectedPortalUids: [], + selectedRouteIds: [], + }); + const user = userEvent.setup(); + flowMocks.context = resource.context; + + render( + + + , + ); + + const allowAccess = await screen.findByRole('checkbox', { name: 'Allow access to AI workspace' }); + expect( + screen.queryByRole('button', { name: 'Configure routes permissions for AI workspace' }), + ).not.toBeInTheDocument(); + + await act(async () => { + await user.click(allowAccess); + }); + + await waitFor(() => { + expect(resource.rolePortalAdd).toHaveBeenCalledWith({ values: ['ai-workspace'] }); + }); + expect(resource.request).not.toHaveBeenCalledWith( + expect.objectContaining({ + url: 'desktopRoutes:listRolePermissionTargets', + }), + ); + expect(resource.routePermissionList).not.toHaveBeenCalled(); + expect(resource.routeDefaultPolicyList).not.toHaveBeenCalled(); + }); + + it('should not expose route permissions for missing or unknown portal types', async () => { + const resource = createMultiPortalPermissionResources({ + portals: [ + { + uid: 'missing-type-workspace', + title: 'Missing type workspace', + portalType: null, + }, + { + uid: 'unknown-type-workspace', + title: 'Unknown type workspace', + portalType: 'unknown', + }, + ], + selectedPortalUids: [], + selectedRouteIds: [], + }); + flowMocks.context = resource.context; + + render( + + + , + ); + + expect(await screen.findByText('Missing type workspace')).toBeInTheDocument(); + expect(screen.getByText('Unknown type workspace')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Configure routes permissions for Missing type workspace' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Configure routes permissions for Unknown type workspace' }), + ).not.toBeInTheDocument(); + expect(resource.request).not.toHaveBeenCalledWith( + expect.objectContaining({ + url: 'desktopRoutes:listRolePermissionTargets', + }), + ); + }); + it('should include hidden descendants when bulk granting visible portal routes', async () => { const resource = createMultiPortalPermissionResources({ routes: [ @@ -402,11 +569,18 @@ type MultiPortalPermissionResourceOptions = { } | null; routeDefaultPolicyCreateError?: Error; routeDefaultPolicyUpdateError?: Error; + portals?: TestPortalRecord[]; routes?: TestRouteRecord[]; selectedPortalUids: string[]; selectedRouteIds: number[]; }; +type TestPortalRecord = { + uid: string; + title: string; + portalType?: string | null; +}; + function createMultiPortalPermissionResources(options: MultiPortalPermissionResourceOptions) { const selectedPortalUids = new Set(options.selectedPortalUids); const selectedRouteIds = new Set(options.selectedRouteIds); @@ -426,10 +600,11 @@ function createMultiPortalPermissionResources(options: MultiPortalPermissionReso allowNewMenu: options.routeDefaultPolicy.allowNewMenu, } : undefined; - const portals = [ + const portals = options.portals ?? [ { uid: 'customer-portal', title: 'Customer portal', + portalType: 'no-code', }, ]; const routes = options.routes ?? [ @@ -471,6 +646,17 @@ function createMultiPortalPermissionResources(options: MultiPortalPermissionReso values.forEach((uid) => selectedPortalUids.delete(uid)); }); const rolesUpdate = vi.fn(async () => undefined); + const layoutRoutePermissionList = vi.fn(async () => ({ + data: { + data: Array.from(selectedRouteIds).map((id) => ({ id })), + }, + })); + const layoutRoutePermissionAdd = vi.fn(async ({ values }: { values: number[] }) => { + values.forEach((id) => selectedRouteIds.add(id)); + }); + const layoutRoutePermissionRemove = vi.fn(async ({ values }: { values: number[] }) => { + values.forEach((id) => selectedRouteIds.delete(id)); + }); const routePermissionList = vi.fn(async () => ({ data: { data: Array.from(selectedRouteIds).map((desktopRouteId) => ({ @@ -539,6 +725,13 @@ function createMultiPortalPermissionResources(options: MultiPortalPermissionReso update: routeDefaultPolicyUpdate, }; } + if (name === 'roles.desktopRoutes' && sourceId === 'portal-member') { + return { + add: layoutRoutePermissionAdd, + list: layoutRoutePermissionList, + remove: layoutRoutePermissionRemove, + }; + } if (name === 'roles') { return { update: rolesUpdate, @@ -558,6 +751,9 @@ function createMultiPortalPermissionResources(options: MultiPortalPermissionReso success: messageSuccess, }, }, + layoutRoutePermissionAdd, + layoutRoutePermissionList, + layoutRoutePermissionRemove, messageSuccess, request, resource, diff --git a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/plugin.test.tsx b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/plugin.test.tsx index cb6de781849..762072179a9 100644 --- a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/plugin.test.tsx +++ b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/__tests__/plugin.test.tsx @@ -20,15 +20,23 @@ import { type MultiPortalRuntimeRecord, } from '../layoutRegistration'; import PluginMultiPortalClientV2 from '../plugin'; -import { installMultiPortalRouteRepositoryScope } from '../routeRepositoryScope'; +import { installMultiPortalRouteRepositoryScope, type MultiPortalRouteScopeDescriptor } from '../routeRepositoryScope'; import packageJson from '../../../package.json'; const UI_LAYOUT_TYPE_DESKTOP = 'desktop'; const UI_LAYOUT_TYPE_MOBILE = 'mobile'; +function createPortalScope(portalUid: string): MultiPortalRouteScopeDescriptor { + return { + cacheKey: getMultiPortalRouteScopeCacheKey(portalUid), + portalUid, + }; +} + const desktopPortal: MultiPortalRuntimeRecord = { uid: 'desktop-portal-model', title: 'Desktop portal', + portalType: 'no-code', portalName: 'portalDesktop', routePath: '/portal-desktop', authCheck: true, @@ -44,6 +52,12 @@ function createLayoutManager(options: { registeredRouteNames?: string[] } = {}) const registeredRouteNames = new Set(options.registeredRouteNames || []); return { hasLayout: vi.fn((routeName: string) => registeredRouteNames.has(routeName)), + listLayouts: vi.fn(() => + Array.from(registeredRouteNames, (routeName, index) => ({ + routeName, + uid: `existing-layout-${index}`, + })), + ), registerLayout: vi.fn(), }; } @@ -105,9 +119,9 @@ describe('PluginMultiPortalClientV2', () => { it('should describe multi-portal management consistently', () => { expect(packageJson.description).toBe( - 'Provides multi-portal management with separate layouts and menus for different entry points.', + 'Provides built-in Portal registration, entry access, and route permissions for Client V2.', ); - expect(packageJson['description.zh-CN']).toBe('提供多 Portal 管理能力,可为不同访问入口配置独立布局和菜单。'); + expect(packageJson['description.zh-CN']).toBe('为 Client V2 提供内置 Portal 注册、入口访问与路由权限管理。'); }); it('should depend on the stable plugin-ui-layout client-v2 package entry', () => { @@ -179,7 +193,29 @@ describe('PluginMultiPortalClientV2', () => { childPageModelClass: 'MultiPortalMobileChildPageModel', authCheck: false, }); + expect( + toMultiPortalLayoutRegisterOptions({ + ...desktopPortal, + uid: '__default_mobile__', + portalName: 'mobile', + routePath: '/mobile', + uiLayout: { + layoutType: UI_LAYOUT_TYPE_MOBILE, + routeName: 'mobile', + routePath: '/mobile', + }, + }), + ).toEqual({ + routeName: 'mobile', + routePath: '/mobile', + uid: '__default_mobile__', + layoutModelClass: 'MobileLayoutModel', + rootPageModelClass: 'MobileRootPageModel', + childPageModelClass: 'MobileChildPageModel', + authCheck: true, + }); expect(toMultiPortalLayoutRegisterOptions({ ...desktopPortal, enabled: false })).toBeNull(); + expect(toMultiPortalLayoutRegisterOptions({ ...desktopPortal, portalType: 'ai' })).toBeNull(); expect(toMultiPortalLayoutRegisterOptions({ ...desktopPortal, uiLayout: { layoutType: 'unknown' } })).toBeNull(); }); @@ -197,6 +233,12 @@ describe('PluginMultiPortalClientV2', () => { routePath: '/mobile', }, }; + const fixedMobilePortal: MultiPortalRuntimeRecord = { + ...mobilePortal, + uid: '__default_mobile__', + portalName: 'mobile', + routePath: '/mobile', + }; const addPermissionsTab = vi.fn(); const app = { i18n: { @@ -205,6 +247,7 @@ describe('PluginMultiPortalClientV2', () => { pluginSettingsManager: { addMenuItem: vi.fn(), addPageTabItem: vi.fn(), + getRouteName: vi.fn(() => 'admin.settings.'), }, flowEngine: { registerModels: vi.fn(), @@ -220,11 +263,20 @@ describe('PluginMultiPortalClientV2', () => { apiClient: { request: vi.fn().mockResolvedValue({ data: { - data: [desktopPortal, mobilePortal, { ...desktopPortal, uid: 'disabled-portal', enabled: false }], + data: [ + desktopPortal, + mobilePortal, + fixedMobilePortal, + { ...desktopPortal, uid: 'ai-portal', portalType: 'ai' }, + { ...desktopPortal, uid: 'disabled-portal', enabled: false }, + ], }, }), }, layoutManager: createLayoutManager(), + router: { + add: vi.fn(), + }, }; const plugin = new PluginMultiPortalClientV2({}, app as never); @@ -257,6 +309,7 @@ describe('PluginMultiPortalClientV2', () => { icon: 'PartitionOutlined', aclSnippet: 'pm.multi-portal', showTabs: true, + sort: -300, }); expect(app.pluginSettingsManager.addPageTabItem).toHaveBeenCalledTimes(1); expect(app.pluginSettingsManager.addPageTabItem).toHaveBeenCalledWith({ @@ -278,7 +331,7 @@ describe('PluginMultiPortalClientV2', () => { method: 'get', skipNotify: true, }); - expect(app.layoutManager.registerLayout).toHaveBeenCalledTimes(2); + expect(app.layoutManager.registerLayout).toHaveBeenCalledTimes(3); expect(app.layoutManager.registerLayout).toHaveBeenNthCalledWith(1, { routeName: 'portalDesktop', routePath: '/portal-desktop', @@ -295,6 +348,64 @@ describe('PluginMultiPortalClientV2', () => { childPageModelClass: 'MultiPortalMobileChildPageModel', authCheck: false, }); + expect(app.layoutManager.registerLayout).toHaveBeenNthCalledWith(3, { + routeName: 'mobile', + routePath: '/mobile', + uid: '__default_mobile__', + layoutModelClass: 'MobileLayoutModel', + rootPageModelClass: 'MobileRootPageModel', + childPageModelClass: 'MobileChildPageModel', + authCheck: false, + }); + expect(app.router.add).toHaveBeenCalledWith('root', { + path: '/', + Component: expect.any(Function), + authCheck: true, + }); + }); + + it('should keep scoped Settings registration without loading runtime portals', async () => { + const addPermissionsTab = vi.fn(); + const app = { + i18n: { + t: vi.fn((key: string) => key), + }, + pluginSettingsManager: { + addMenuItem: vi.fn(), + addPageTabItem: vi.fn(), + getRouteName: vi.fn(() => 'settings.'), + getRoutePath: vi.fn(() => '/'), + }, + flowEngine: { + registerModels: vi.fn(), + registerModelLoaders: vi.fn(), + }, + pm: { + get: vi.fn(() => ({ + settingsUI: { + addPermissionsTab, + }, + })), + }, + apiClient: { + request: vi.fn(), + }, + layoutManager: createLayoutManager(), + router: { + add: vi.fn(), + }, + }; + + const plugin = new PluginMultiPortalClientV2({}, app as never); + await plugin.load(); + + expect(app.pluginSettingsManager.addMenuItem).toHaveBeenCalledWith( + expect.objectContaining({ key: 'multi-portal' }), + ); + expect(addPermissionsTab).toHaveBeenCalledWith(expect.objectContaining({ key: 'multi-portals' })); + expect(app.apiClient.request).not.toHaveBeenCalled(); + expect(app.layoutManager.registerLayout).not.toHaveBeenCalled(); + expect(app.router.add).not.toHaveBeenCalled(); }); it('should register the portal block model while keeping it hidden from the add block menu', async () => { @@ -461,14 +572,14 @@ describe('PluginMultiPortalClientV2', () => { expect(await screen.findByText('Failed to load portals')).toBeInTheDocument(); }); - it('should scope mobile portal page tabs to the current portal owner', async () => { + it('should scope mobile page tab creation by portal identity without client-owned route relations', async () => { const { MultiPortalMobileRootPageModel, MultiPortalMobileChildPageModel } = await import( '../models/MultiPortalMobilePageModels' ); const flowEngine = new FlowEngine(); flowEngine.context.defineProperty('layout', { value: { - uid: 'portal:mobile-portal-model-tab-test', + uid: 'mobile-portal-model-tab-test', }, }); const request = vi.fn().mockResolvedValue({}); @@ -496,15 +607,13 @@ describe('PluginMultiPortalClientV2', () => { params: [], hideInMenu: false, enableTabs: false, - multiPortals: ['mobile-portal-model-tab-test'], }); expect(rootTabOptions.props?.route).toHaveProperty('schemaUid'); expect(rootTabOptions.props?.route).toHaveProperty('tabSchemaName'); expect(rootTabOptions.props?.route).not.toHaveProperty('uiLayouts'); - expect(childTabOptions.props?.route).toMatchObject({ - multiPortals: ['mobile-portal-model-tab-test'], - }); + expect(rootTabOptions.props?.route).not.toHaveProperty('multiPortals'); expect(childTabOptions.props?.route).not.toHaveProperty('uiLayouts'); + expect(childTabOptions.props?.route).not.toHaveProperty('multiPortals'); const models = (await import('../models/MultiPortalMobilePageModels')) as Record; const RootTabModel = models[rootTabOptions.use as string] as typeof RootPageTabModel; @@ -526,13 +635,17 @@ describe('PluginMultiPortalClientV2', () => { expect(request).toHaveBeenCalledWith( expect.objectContaining({ url: 'desktopRoutes:updateOrCreate', + params: { + filterKeys: ['schemaUid'], + portal: 'mobile-portal-model-tab-test', + }, data: expect.objectContaining({ schemaUid: rootTabOptions.props?.route?.schemaUid, - multiPortals: ['mobile-portal-model-tab-test'], }), }), ); expect(request.mock.calls[0][0].data).not.toHaveProperty('uiLayouts'); + expect(request.mock.calls[0][0].data).not.toHaveProperty('multiPortals'); }); it('should keep portal tab route ownership clean across repeated saves', async () => { @@ -540,7 +653,7 @@ describe('PluginMultiPortalClientV2', () => { const flowEngine = new FlowEngine(); flowEngine.context.defineProperty('layout', { value: { - uid: 'portal:mobile-portal-model-tab-test', + uid: 'mobile-portal-model-tab-test', }, }); const request = vi.fn().mockResolvedValue({ @@ -590,15 +703,20 @@ describe('PluginMultiPortalClientV2', () => { expect(request.mock.calls[0][0]).toEqual( expect.objectContaining({ url: 'desktopRoutes:updateOrCreate', - data: expect.objectContaining({ - multiPortals: ['mobile-portal-model-tab-test'], - }), + params: { + filterKeys: ['schemaUid'], + portal: 'mobile-portal-model-tab-test', + }, }), ); expect(request.mock.calls[0][0].data).not.toHaveProperty('uiLayouts'); + expect(request.mock.calls[0][0].data).not.toHaveProperty('multiPortals'); expect(request.mock.calls[1][0]).toEqual( expect.objectContaining({ url: 'desktopRoutes:update?filter[id]=991', + params: { + portal: 'mobile-portal-model-tab-test', + }, data: expect.objectContaining({ schemaUid: 'portal-tab-schema', }), @@ -606,38 +724,122 @@ describe('PluginMultiPortalClientV2', () => { ); expect(request.mock.calls[1][0].data).not.toHaveProperty('multiPortals'); expect(request.mock.calls[1][0].data).not.toHaveProperty('uiLayouts'); - expect(tabModel.props.route).toMatchObject({ - multiPortals: ['mobile-portal-model-tab-test'], + expect(tabModel.props.route).not.toHaveProperty('multiPortals'); + expect(tabModel.props.route).not.toHaveProperty('uiLayouts'); + }); + + it('should attach the fixed Mobile Portal identity to root route requests', async () => { + const { MultiPortalMobileRootPageModel } = await import('../models/MultiPortalMobilePageModels'); + const flowEngine = new FlowEngine(); + flowEngine.context.defineProperty('layout', { + value: { + uid: '__default_mobile__', + }, + }); + const request = vi.fn().mockResolvedValue({}); + flowEngine.context.defineProperty('api', { + value: { request }, + }); + flowEngine.context.defineProperty('t', { + value: (value: string) => value, + }); + const rootPageModel = new MultiPortalMobileRootPageModel({ + flowEngine, + props: { + routeId: 'mobile-layout-root-route', + }, + } as never); + rootPageModel.stepParams = { + pageSettings: { + general: { + enableTabs: true, + }, + }, + }; + + await rootPageModel.saveStepParams(); + await rootPageModel.context.api.request({ + url: '/desktopRoutes:listAccessible', + method: 'get', + params: { + layout: 'mobile-layout-model', + portal: 'forged-portal', + }, + }); + + expect(request).toHaveBeenNthCalledWith(1, { + url: 'desktopRoutes:update?filter[id]=mobile-layout-root-route', + method: 'post', + params: { + portal: '__default_mobile__', + }, + data: { + enableTabs: true, + }, + }); + expect(request).toHaveBeenNthCalledWith(2, { + url: '/desktopRoutes:listAccessible', + method: 'get', + params: { + portal: '__default_mobile__', + }, }); }); - it('should request portal scoped routes and keep route caches isolated', async () => { + it('should scope every route operation by portal and keep route caches isolated', async () => { const request = vi .fn() .mockResolvedValueOnce({ data: { data: [{ schemaUid: 'portal-page' }] } }) .mockResolvedValueOnce({ data: { data: [{ schemaUid: 'layout-page' }] } }); const create = vi.fn().mockResolvedValue({ data: { data: {} } }); + const update = vi.fn().mockResolvedValue({ data: { data: {} } }); + const destroy = vi.fn().mockResolvedValue({ data: { data: {} } }); + const move = vi.fn().mockResolvedValue({ data: { data: {} } }); const repository = new RouteRepository({ api: { request, resource: vi.fn(() => ({ create, + update, + destroy, + move, })), }, } as never); - installMultiPortalRouteRepositoryScope(repository, () => ['customer-portal']); + installMultiPortalRouteRepositoryScope(repository, () => [ + createPortalScope('customer-portal'), + createPortalScope('__default_mobile__'), + ]); const deactivatePortal = repository.activateLayout({ uid: 'customer-portal' }); await repository.refreshAccessible(); expect(repository.listAccessible().map((route) => route.schemaUid)).toEqual(['portal-page']); - await repository.createRoute({ title: 'Portal page' }, { refreshAfterMutation: false }); + await repository.createRoute( + { + title: 'Portal page', + uiLayouts: ['forged-layout'], + multiPortals: ['forged-portal'], + } as never, + { refreshAfterMutation: false }, + ); + await repository.updateRoute( + 11, + { + title: 'Updated portal page', + uiLayouts: ['forged-layout'], + multiPortals: ['forged-portal'], + } as never, + { refreshAfterMutation: false }, + ); + await repository.deleteRoute(12, { refreshAfterMutation: false }); + await repository.moveRoute({ sourceId: 13, targetId: 14, refreshAfterMove: false }); deactivatePortal(); - const deactivateLayout = repository.activateLayout({ uid: 'mobile-layout-model' }); + const deactivateFixedMobilePortal = repository.activateLayout({ uid: '__default_mobile__' }); await repository.refreshAccessible(); expect(repository.listAccessible().map((route) => route.schemaUid)).toEqual(['layout-page']); - deactivateLayout(); + deactivateFixedMobilePortal(); const reactivatePortal = repository.activateLayout({ uid: 'customer-portal' }); expect(repository.listAccessible().map((route) => route.schemaUid)).toEqual(['portal-page']); @@ -656,7 +858,7 @@ describe('PluginMultiPortalClientV2', () => { params: { tree: true, sort: 'sort', - layout: 'mobile-layout-model', + portal: '__default_mobile__', }, }); expect(create).toHaveBeenCalledWith({ @@ -665,9 +867,25 @@ describe('PluginMultiPortalClientV2', () => { }, portal: 'customer-portal', }); + expect(update).toHaveBeenCalledWith({ + filterByTk: 11, + values: { + title: 'Updated portal page', + }, + portal: 'customer-portal', + }); + expect(destroy).toHaveBeenCalledWith({ + filterByTk: 12, + portal: 'customer-portal', + }); + expect(move).toHaveBeenCalledWith({ + sourceId: 13, + targetId: 14, + portal: 'customer-portal', + }); }); - it('should unwrap prefixed mobile portal route scope keys before requesting routes', async () => { + it('should keep the raw mobile Portal UID separate from its route cache key', async () => { const request = vi.fn().mockResolvedValue({ data: { data: [{ schemaUid: 'mobile-portal-page' }] } }); const create = vi.fn().mockResolvedValue({ data: { data: {} } }); const repository = new RouteRepository({ @@ -679,15 +897,15 @@ describe('PluginMultiPortalClientV2', () => { }, } as never); - installMultiPortalRouteRepositoryScope(repository, () => ['mobile-portal']); + installMultiPortalRouteRepositoryScope(repository, () => [createPortalScope('mobile-portal')]); - const deactivatePortal = repository.activateLayout({ uid: 'portal:mobile-portal' }); + const deactivatePortal = repository.activateLayout({ uid: 'mobile-portal' }); await repository.refreshAccessible(); expect(repository.listAccessible().map((route) => route.schemaUid)).toEqual(['mobile-portal-page']); await repository.createRoute({ title: 'Mobile portal page' }, { refreshAfterMutation: false }); deactivatePortal(); - const reactivatePortal = repository.activateLayout({ uid: 'portal:mobile-portal' }); + const reactivatePortal = repository.activateLayout({ uid: 'mobile-portal' }); expect(repository.listAccessible().map((route) => route.schemaUid)).toEqual(['mobile-portal-page']); reactivatePortal(); @@ -721,7 +939,7 @@ describe('PluginMultiPortalClientV2', () => { }, } as never); - installMultiPortalRouteRepositoryScope(repository, () => ['customer-portal']); + installMultiPortalRouteRepositoryScope(repository, () => [createPortalScope('customer-portal')]); const deactivatePortal = repository.activateLayout({ uid: 'customer-portal' }); const olderRefresh = repository.refreshAccessible(); @@ -788,27 +1006,29 @@ describe('PluginMultiPortalClientV2', () => { expect(disabledMatches.some((match) => match.route.path === '/disabled-portal')).toBe(false); }); - it('should not register a portalName that is already registered', async () => { + it('should abort before registration when a portalName is already registered', async () => { const layoutManager = createLayoutManager({ registeredRouteNames: ['portalDesktop'], }); - await registerMultiPortalsFromApi({ - apiClient: { - request: vi.fn().mockResolvedValue({ - data: { - data: [desktopPortal], - }, - }), - }, - layoutManager, - }); + await expect( + registerMultiPortalsFromApi({ + apiClient: { + request: vi.fn().mockResolvedValue({ + data: { + data: [desktopPortal], + }, + }), + }, + layoutManager, + }), + ).rejects.toThrow("Duplicate portal route name 'portalDesktop'."); expect(layoutManager.hasLayout).toHaveBeenCalledWith('portalDesktop'); expect(layoutManager.registerLayout).not.toHaveBeenCalled(); }); - it('should not let skipped or failed portal registrations pollute route repository scopes', async () => { + it('should abort a conflicting batch before registration without polluting route repository scopes', async () => { const request = vi .fn() .mockResolvedValueOnce({ data: { data: [{ schemaUid: 'admin-layout-page' }] } }) @@ -823,16 +1043,12 @@ describe('PluginMultiPortalClientV2', () => { } as never); const layoutManager = { hasLayout: vi.fn((routeName: string) => routeName === 'existingPortal'), - registerLayout: vi.fn((options) => { - if (options.uid === 'failed-portal-model') { - throw new Error('uid conflict'); - } - }), + listLayouts: vi.fn((): Array<{ routeName: string; uid: string }> => []), + registerLayout: vi.fn(), }; - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - try { - await registerMultiPortalsFromApi({ + await expect( + registerMultiPortalsFromApi({ apiClient: { request: vi.fn().mockResolvedValue({ data: { @@ -865,22 +1081,10 @@ describe('PluginMultiPortalClientV2', () => { }, }, layoutManager, - }); - } finally { - warnSpy.mockRestore(); - } + }), + ).rejects.toThrow("Duplicate portal route name 'existingPortal'."); - expect(layoutManager.registerLayout).toHaveBeenCalledTimes(2); - expect(layoutManager.registerLayout).toHaveBeenCalledWith( - expect.objectContaining({ - uid: 'failed-portal-model', - }), - ); - expect(layoutManager.registerLayout).toHaveBeenCalledWith( - expect.objectContaining({ - uid: 'customer-portal', - }), - ); + expect(layoutManager.registerLayout).not.toHaveBeenCalled(); const deactivateAdminLayout = repository.activateLayout({ uid: 'admin-layout-model' }); await repository.refreshAccessible(); @@ -903,7 +1107,7 @@ describe('PluginMultiPortalClientV2', () => { params: { tree: true, sort: 'sort', - portal: 'customer-portal', + layout: 'customer-portal', }, }); }); diff --git a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/layoutRegistration.ts b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/layoutRegistration.ts index e1ca1a7174c..d1d5acb63f9 100644 --- a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/layoutRegistration.ts +++ b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/layoutRegistration.ts @@ -8,6 +8,7 @@ */ import type { Application, LayoutRegisterOptions } from '@nocobase/client-v2'; +import { DEFAULT_MOBILE_MULTI_PORTAL_UID } from '../constants'; import { getMultiPortalRouteScopeCacheKey, installMultiPortalRouteRepositoryScope } from './routeRepositoryScope'; export { getMultiPortalRouteScopeCacheKey }; @@ -15,6 +16,7 @@ export { getMultiPortalRouteScopeCacheKey }; export type MultiPortalRuntimeRecord = { uid: string; title?: string; + portalType?: string; portalName: string; routePath: string; authCheck: boolean; @@ -38,7 +40,7 @@ type MultiPortalRegistrationApp = { flowEngine?: { context?: unknown; }; - layoutManager: Pick; + layoutManager: Pick; }; const UI_LAYOUT_TYPE_DESKTOP = 'desktop'; @@ -47,6 +49,9 @@ const ADMIN_LAYOUT_MODEL_CLASS = 'AdminLayoutModel'; const MULTI_PORTAL_MOBILE_LAYOUT_MODEL_CLASS = 'MultiPortalMobileLayoutModel'; const MULTI_PORTAL_MOBILE_ROOT_PAGE_MODEL_CLASS = 'MultiPortalMobileRootPageModel'; const MULTI_PORTAL_MOBILE_CHILD_PAGE_MODEL_CLASS = 'MultiPortalMobileChildPageModel'; +const MOBILE_LAYOUT_MODEL_CLASS = 'MobileLayoutModel'; +const MOBILE_ROOT_PAGE_MODEL_CLASS = 'MobileRootPageModel'; +const MOBILE_CHILD_PAGE_MODEL_CLASS = 'MobileChildPageModel'; const layoutRegisterOptionsByType: Record< string, @@ -62,12 +67,26 @@ const layoutRegisterOptionsByType: Record< }, }; +const layoutModeMobileRegisterOptions = { + layoutModelClass: MOBILE_LAYOUT_MODEL_CLASS, + rootPageModelClass: MOBILE_ROOT_PAGE_MODEL_CLASS, + childPageModelClass: MOBILE_CHILD_PAGE_MODEL_CLASS, +} satisfies Pick; + +function isRuntimePortal(record: MultiPortalRuntimeRecord) { + return record.portalType === 'no-code'; +} + export function toMultiPortalLayoutRegisterOptions(record: MultiPortalRuntimeRecord): LayoutRegisterOptions | null { - if (!record.enabled) { + if (!record.enabled || !isRuntimePortal(record)) { return null; } - const codeDefinedOptions = layoutRegisterOptionsByType[record.uiLayout?.layoutType || '']; + const layoutType = record.uiLayout?.layoutType || ''; + const codeDefinedOptions = + layoutType === UI_LAYOUT_TYPE_MOBILE && record.uid === DEFAULT_MOBILE_MULTI_PORTAL_UID + ? layoutModeMobileRegisterOptions + : layoutRegisterOptionsByType[layoutType]; if (!codeDefinedOptions) { return null; } @@ -95,20 +114,37 @@ export function registerMultiPortalRecords( layoutManager: MultiPortalRegistrationApp['layoutManager'], records: MultiPortalRuntimeRecord[], ) { - const registeredPortalUids: string[] = []; + const candidates: Array<{ options: LayoutRegisterOptions; record: MultiPortalRuntimeRecord }> = []; + const existingPortalUids = new Set(layoutManager.listLayouts().map((layout) => layout.uid)); + const portalUids = new Set(); + const routeNames = new Set(); for (const record of records) { - const options = toMultiPortalLayoutRegisterOptions(record); - if (!options || layoutManager.hasLayout(options.routeName)) { + if (!record.enabled || !isRuntimePortal(record)) { continue; } - - try { - layoutManager.registerLayout(options); - registeredPortalUids.push(record.uid); - } catch (error) { - console.warn(`[NocoBase] plugin-multi-portal failed to register portal '${options.routeName}'.`, error); + const options = toMultiPortalLayoutRegisterOptions(record); + if (!options) { + throw new Error(`Portal '${record.uid}' uses an unknown UI layout type '${record.uiLayout?.layoutType || ''}'.`); } + if (portalUids.has(record.uid)) { + throw new Error(`Duplicate portal uid '${record.uid}'.`); + } + if (existingPortalUids.has(record.uid)) { + throw new Error(`Duplicate portal uid '${record.uid}'.`); + } + if (routeNames.has(options.routeName) || layoutManager.hasLayout(options.routeName)) { + throw new Error(`Duplicate portal route name '${options.routeName}'.`); + } + portalUids.add(record.uid); + routeNames.add(options.routeName); + candidates.push({ options, record }); + } + + const registeredPortalUids: string[] = []; + for (const { options, record } of candidates) { + layoutManager.registerLayout(options); + registeredPortalUids.push(record.uid); } return registeredPortalUids; @@ -128,14 +164,14 @@ function getRouteRepository(app: MultiPortalRegistrationApp) { } export async function registerMultiPortalsFromApi(app: MultiPortalRegistrationApp) { - let records: MultiPortalRuntimeRecord[]; - try { - records = await fetchMultiPortals(app.apiClient); - } catch (error) { - console.warn('[NocoBase] plugin-multi-portal failed to load portals.', error); - return; - } - + const records = await fetchMultiPortals(app.apiClient); const registeredPortalUids = registerMultiPortalRecords(app.layoutManager, records); - installMultiPortalRouteRepositoryScope(getRouteRepository(app), () => registeredPortalUids); + const registeredPortalUidSet = new Set(registeredPortalUids); + const registeredPortalScopes = records + .filter((record) => registeredPortalUidSet.has(record.uid)) + .map((record) => ({ + cacheKey: getMultiPortalRouteScopeCacheKey(record.uid), + portalUid: record.uid, + })); + installMultiPortalRouteRepositoryScope(getRouteRepository(app), () => registeredPortalScopes); } diff --git a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/models/MultiPortalMobilePageModels.tsx b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/models/MultiPortalMobilePageModels.tsx index 3ff719827d5..38e59ad9b02 100644 --- a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/models/MultiPortalMobilePageModels.tsx +++ b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/models/MultiPortalMobilePageModels.tsx @@ -10,13 +10,22 @@ import { ChildPageTabModel, RootPageTabModel, type ChildPageModel, type RootPageModel } from '@nocobase/client-v2'; import type { CreateModelOptions } from '@nocobase/flow-engine'; import { MobileChildPageModel, MobileLayoutModel, MobileRootPageModel } from '@nocobase/plugin-ui-layout/client-v2'; -import { getMultiPortalRouteScopeCacheKey, getMultiPortalUidFromRouteScopeCacheKey } from '../routeRepositoryScope'; type RouteWithOwnership = Record & { multiPortals?: unknown; uiLayouts?: unknown; }; +type ApiRequestOptions = Record & { + data?: unknown; + params?: unknown; + url?: unknown; +}; + +type ApiWithRequest = { + request: (options: unknown) => Promise; +}; + function isRouteWithOwnership(route: unknown): route is RouteWithOwnership { return !!route && typeof route === 'object' && !Array.isArray(route); } @@ -31,31 +40,84 @@ function getCurrentPortalUid(model: PortalLayoutContextModel) { return undefined; } - return getMultiPortalUidFromRouteScopeCacheKey(portalUid) || portalUid; + return portalUid; } -function withCurrentPortalRoute(route: unknown, portalUid?: string) { +function withoutRouteOwnership(route: unknown) { if (!isRouteWithOwnership(route)) { return route; } const { uiLayouts: _uiLayouts, multiPortals: _multiPortals, ...routeValues } = route; - - if (!portalUid) { - return routeValues; - } - - return { - ...routeValues, - multiPortals: [portalUid], - }; + return routeValues; } -function withCurrentPortalTabOptions( - model: RootPageModel | ChildPageModel, - options: CreateModelOptions, - tabModelClass: string, -) { +function isDesktopRoutesRequest(options: unknown): options is ApiRequestOptions { + if (!options || typeof options !== 'object' || Array.isArray(options)) { + return false; + } + + const url = (options as ApiRequestOptions).url; + return typeof url === 'string' && url.replace(/^\/+/, '').startsWith('desktopRoutes:'); +} + +function isApiWithRequest(api: unknown): api is ApiWithRequest { + if (!api || typeof api !== 'object' || Array.isArray(api) || !('request' in api)) { + return false; + } + + return typeof api.request === 'function'; +} + +function withPortalIdentity(model: PortalLayoutContextModel, options: unknown) { + if (!isDesktopRoutesRequest(options)) { + return options; + } + + const portalUid = getCurrentPortalUid(model); + if (!portalUid) { + return options; + } + + const currentParams = + options.params && typeof options.params === 'object' && !Array.isArray(options.params) ? options.params : {}; + const { layout: _layout, portal: _portal, ...params } = currentParams as Record; + + const scopedOptions = { + ...options, + params: { + ...params, + portal: portalUid, + }, + }; + + return Object.prototype.hasOwnProperty.call(options, 'data') + ? { ...scopedOptions, data: withoutRouteOwnership(options.data) } + : scopedOptions; +} + +function installPortalIdentityApi(model: PortalLayoutContextModel) { + const api: unknown = model.flowEngine.context.api; + if (!isApiWithRequest(api)) { + return; + } + + const scopedApi = new Proxy(api, { + get(apiTarget, key) { + if (key === 'request') { + return (options: unknown) => apiTarget.request(withPortalIdentity(model, options)); + } + + const value = Reflect.get(apiTarget, key, apiTarget); + return typeof value === 'function' ? value.bind(apiTarget) : value; + }, + }); + model.context.defineProperty('api', { + value: scopedApi, + }); +} + +function withCurrentPortalTabOptions(options: CreateModelOptions, tabModelClass: string) { const route = options.props?.route; return { @@ -63,37 +125,25 @@ function withCurrentPortalTabOptions( use: tabModelClass, props: { ...options.props, - route: withCurrentPortalRoute(route, getCurrentPortalUid(model)), + route: withoutRouteOwnership(route), }, }; } function normalizePortalTabRouteOwnership(model: RootPageTabModel | ChildPageTabModel) { - model.setProps('route', withCurrentPortalRoute(model.props.route, getCurrentPortalUid(model))); + model.setProps('route', withoutRouteOwnership(model.props.route)); } -export class MultiPortalMobileLayoutModel extends MobileLayoutModel { - get layout() { - const layout = super.layout; - - return { - ...layout, - uid: getMultiPortalRouteScopeCacheKey(layout.uid), - }; - } -} +export class MultiPortalMobileLayoutModel extends MobileLayoutModel {} export class MultiPortalMobileRootPageModel extends MobileRootPageModel { constructor(options: ConstructorParameters[0]) { super(options); + installPortalIdentityApi(this); const createUiLayoutPageTabModelOptions = this.createPageTabModelOptions.bind(this); this.createPageTabModelOptions = () => { - return withCurrentPortalTabOptions( - this, - createUiLayoutPageTabModelOptions(), - 'MultiPortalMobileRootPageTabModel', - ); + return withCurrentPortalTabOptions(createUiLayoutPageTabModelOptions(), 'MultiPortalMobileRootPageTabModel'); }; } } @@ -101,19 +151,22 @@ export class MultiPortalMobileRootPageModel extends MobileRootPageModel { export class MultiPortalMobileChildPageModel extends MobileChildPageModel { constructor(options: ConstructorParameters[0]) { super(options); + installPortalIdentityApi(this); const createUiLayoutPageTabModelOptions = this.createPageTabModelOptions.bind(this); this.createPageTabModelOptions = () => { - return withCurrentPortalTabOptions( - this, - createUiLayoutPageTabModelOptions(), - 'MultiPortalMobileChildPageTabModel', - ); + return withCurrentPortalTabOptions(createUiLayoutPageTabModelOptions(), 'MultiPortalMobileChildPageTabModel'); }; } } export class MultiPortalMobileRootPageTabModel extends RootPageTabModel { + constructor(options: ConstructorParameters[0]) { + super(options); + installPortalIdentityApi(this); + normalizePortalTabRouteOwnership(this); + } + async save() { normalizePortalTabRouteOwnership(this); await super.save(); @@ -122,8 +175,15 @@ export class MultiPortalMobileRootPageTabModel extends RootPageTabModel { } export class MultiPortalMobileChildPageTabModel extends ChildPageTabModel { - onInit(options: ConstructorParameters[0]) { - super.onInit(options); + constructor(options: ConstructorParameters[0]) { + super(options); + installPortalIdentityApi(this); + normalizePortalTabRouteOwnership(this); + } + + async save() { + normalizePortalTabRouteOwnership(this); + await super.save(); normalizePortalTabRouteOwnership(this); } } diff --git a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/pages/MultiPortalsPage.tsx b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/pages/MultiPortalsPage.tsx index f8a225340e3..640dad7ffbe 100644 --- a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/pages/MultiPortalsPage.tsx +++ b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/pages/MultiPortalsPage.tsx @@ -17,6 +17,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { getPortalEntryActionStore } from '../entryActions/portalEntryActionStore'; import { useT } from '../locale'; import { getMultiPortalRouteUrl } from '../routeUrl'; +import PortalRoutesDrawer from './PortalRoutesDrawer'; type MultiPortalPrimaryKey = string; type PortalSourceStorage = 'nocobase' | 'git'; @@ -33,7 +34,6 @@ type MultiPortalOptions = { }; export type MultiPortalRecord = MultiPortalFormValues & { - defaultPortal?: boolean; uiLayout?: { layoutType?: string; title?: string; @@ -171,7 +171,6 @@ const describedRadioCss = ` } `; -const DEFAULT_PORTAL_UIDS = new Set(['__default_portal__']); const portalSlugPattern = /^[a-z0-9_-]+$/; const IconPickerFormControl = React.forwardRef>( @@ -342,13 +341,6 @@ function toFormDraftValues(record: MultiPortalRecord): MultiPortalFormDraftValue }; } -function withDefaultPortalFlag(record: MultiPortalRecord): MultiPortalRecord { - return { - ...record, - defaultPortal: DEFAULT_PORTAL_UIDS.has(record.uid), - }; -} - const MultiPortalsPage: React.FC = () => { const t = useT(); const ctx = useFlowContext(); @@ -369,7 +361,7 @@ const MultiPortalsPage: React.FC = () => { }); const { data: listResp, loading } = listRequest; const records = useMemo(() => { - return Array.isArray(listResp?.data) ? listResp.data.map(withDefaultPortalFlag) : []; + return Array.isArray(listResp?.data) ? listResp.data : []; }, [listResp?.data]); const pagination = useMemo(() => { const meta = listResp?.meta; @@ -408,6 +400,17 @@ const MultiPortalsPage: React.FC = () => { [ctx.viewer, refreshPortals, token.screenMD], ); + const openRoutesDrawer = useCallback( + (record: MultiPortalRecord) => { + ctx.viewer.drawer({ + width: '80%', + closable: true, + content: () => , + }); + }, + [ctx.viewer], + ); + const handleDelete = useCallback( (filterByTk: MultiPortalPrimaryKey | MultiPortalPrimaryKey[], options: { isBatch?: boolean } = {}) => { modal.confirm({ @@ -512,6 +515,16 @@ const MultiPortalsPage: React.FC = () => { + {normalizePortalType(record.portalType) === DEFAULT_PORTAL_TYPE ? ( + + ) : null} @@ -519,7 +532,7 @@ const MultiPortalsPage: React.FC = () => { ), }, ], - [ctx.app, handleDelete, handleToggleEnabled, openFormDrawer, t, updatingEnabledRowKeys], + [ctx.app, handleDelete, handleToggleEnabled, openFormDrawer, openRoutesDrawer, t, updatingEnabledRowKeys], ); const handleTableChange = useCallback['onChange']>>( @@ -682,7 +695,7 @@ function MultiPortalForm(props: { record?: MultiPortalRecord; onSubmitted: () => }, ]} > - + htmlFor="multi-portal-portal-type-no-code" rules={[{ required: true, message: t('The field value is required') }]} > - + ]} > - - - - - - - ); - - return ( - + + + + + + + + + } + onOpenChange={setOpen} + open={open} + placement="bottomLeft" + trigger="click" + > @@ -639,23 +550,21 @@ function RoutesFilterButton(props: { onApply: (values: RouteFilterValues) => voi ); } -function RoutesTable({ layout }: { layout: RouteLayoutConfig }) { - const ctx = useFlowContext(); +function PortalRoutesTable({ portal }: { portal: MultiPortalRecord }) { + const ctx = useFlowContext(); const t = useT(); - const tRef = useRef(t); - const { modal } = AntdApp.useApp(); + const tRef = React.useRef(t); + const { message, modal } = AntdApp.useApp(); const { token } = theme.useToken(); const [routes, setRoutes] = useState([]); const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); - const [editingRoute, setEditingRoute] = useState(null); - const [parentRoute, setParentRoute] = useState(null); - const [editorOpen, setEditorOpen] = useState(false); const [filterValues, setFilterValues] = useState({}); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const desktopRoutesResource = useMemo(() => ctx.api.resource('desktopRoutes'), [ctx.api]); + const portalUid = portal.uid; + const mobile = portal.uiLayout?.layoutType === 'mobile'; - useEffect(() => { + React.useEffect(() => { tRef.current = t; }, [t]); @@ -666,8 +575,8 @@ function RoutesTable({ layout }: { layout: RouteLayoutConfig }) { url: '/desktopRoutes:list', method: 'get', params: { - filter: createDesktopRouteLayoutPermissionFilter(layout.uid), paginate: false, + portal: portalUid, sort: 'sort', tree: true, }, @@ -676,136 +585,108 @@ function RoutesTable({ layout }: { layout: RouteLayoutConfig }) { setRoutes(toRoutePayload(response?.data).data ?? []); setSelectedRowKeys([]); } catch { - const translate = tRef.current; - ctx.message?.error?.(translate('Failed to load routes for {{layout}}', { layout: translate(layout.label) })); + message.error(tRef.current('Failed to load routes')); setRoutes([]); } finally { setLoading(false); } - }, [ctx.api, ctx.message, layout.label, layout.uid]); + }, [ctx.api, message, portalUid]); - useEffect(() => { - async function run() { - await loadRoutes(); - } - run().catch(() => undefined); + React.useEffect(() => { + loadRoutes().catch(() => undefined); }, [loadRoutes]); const refreshRoutesAfterMutation = useCallback(async () => { await loadRoutes(); - if (layout.uid === DEFAULT_ADMIN_UI_LAYOUT.uid) { - await ctx.routeRepository?.refreshAccessible?.(); - } - }, [ctx.routeRepository, layout.uid, loadRoutes]); + }, [loadRoutes]); - const openAddModal = useCallback(() => { - setEditingRoute(null); - setParentRoute(null); - setEditorOpen(true); - }, []); - - const openAddChildModal = useCallback((route: NocoBaseDesktopRoute) => { - setEditingRoute(null); - setParentRoute(route); - setEditorOpen(true); - }, []); - - const openEditModal = useCallback( - (route: NocoBaseDesktopRoute) => { - setEditingRoute(findRouteById(routes, route.id) ?? route); - setParentRoute(null); - setEditorOpen(true); + const submitRoute = useCallback( + async (params: { + editingRoute?: NocoBaseDesktopRoute | null; + parentRoute?: NocoBaseDesktopRoute | null; + values: RouteFormValues; + }) => { + const { editingRoute, parentRoute, values } = params; + if (editingRoute?.id !== undefined) { + const shouldSyncTabVisibility = + isPageRouteType(editingRoute.type) && editingRoute.enableTabs !== !!values.enableTabs; + await desktopRoutesResource.update({ + filterByTk: editingRoute.id, + portal: portalUid, + values: normalizeRouteValues(values, editingRoute, { mobile }), + }); + if (shouldSyncTabVisibility) { + for (const childRoute of getDirectTabRouteChildren(editingRoute)) { + if (childRoute.id === undefined) { + continue; + } + await desktopRoutesResource.update({ + filterByTk: childRoute.id, + portal: portalUid, + values: { hidden: !values.enableTabs }, + }); + } + } + message.success(t('Updated successfully')); + } else { + await desktopRoutesResource.create({ + portal: portalUid, + values: { + ...normalizeRouteValues(values, undefined, { mobile, withInitialPageTab: true }), + ...(parentRoute?.id !== undefined ? { parentId: parentRoute.id } : {}), + }, + }); + message.success(t('Saved successfully')); + } + await refreshRoutesAfterMutation(); }, - [routes], + [desktopRoutesResource, message, mobile, portalUid, refreshRoutesAfterMutation, t], ); - const closeEditor = useCallback(() => { - setEditorOpen(false); - setEditingRoute(null); - setParentRoute(null); - }, []); - - const handleSubmit = useCallback( - async (values: RouteFormValues) => { - setSaving(true); - try { - if (editingRoute?.id !== undefined) { - const shouldSyncTabVisibility = - isPageRouteType(editingRoute.type) && editingRoute.enableTabs !== !!values.enableTabs; - await desktopRoutesResource.update({ - filterByTk: editingRoute.id, - layout: layout.uid, - values: normalizeRouteValues(values, editingRoute, { mobile: layout.mobile }), - }); - if (shouldSyncTabVisibility) { - for (const childRoute of getDirectTabRouteChildren(editingRoute)) { - if (childRoute.id === undefined) { - continue; - } - await desktopRoutesResource.update({ - filterByTk: childRoute.id, - layout: layout.uid, - values: { - hidden: !values.enableTabs, - }, - }); - } - } - ctx.message?.success?.(t('Updated successfully')); - } else { - await desktopRoutesResource.create({ - layout: layout.uid, - values: { - ...normalizeRouteValues(values, undefined, { mobile: layout.mobile, withInitialPageTab: true }), - ...(parentRoute?.id !== undefined ? { parentId: parentRoute.id } : {}), - }, - }); - ctx.message?.success?.(t('Saved successfully')); - } - closeEditor(); - await refreshRoutesAfterMutation(); - } finally { - setSaving(false); - } + const openRouteEditor = useCallback( + (params: { editingRoute?: NocoBaseDesktopRoute | null; parentRoute?: NocoBaseDesktopRoute | null }) => { + const editingRoute = params.editingRoute + ? findRouteById(routes, params.editingRoute.id) ?? params.editingRoute + : null; + const parentRoute = params.parentRoute ?? null; + ctx.viewer.drawer({ + width: token.screenSM, + closable: true, + content: () => ( + submitRoute({ editingRoute, parentRoute, values })} + parentRoute={parentRoute} + title={editingRoute ? t('Edit route') : parentRoute ? t('Add child route') : t('Add new')} + /> + ), + }); }, - [ - closeEditor, - ctx.message, - desktopRoutesResource, - editingRoute, - layout.mobile, - layout.uid, - parentRoute, - refreshRoutesAfterMutation, - t, - ], + [ctx.viewer, mobile, routes, submitRoute, t, token.screenSM], ); const handleDelete = useCallback( - async (route: NocoBaseDesktopRoute) => { - if (route.id === undefined) { - return; - } + async (filterByTk: Array | number | string) => { await desktopRoutesResource.destroy({ - filterByTk: route.id, - layout: layout.uid, + filterByTk, + portal: portalUid, }); - ctx.message?.success?.(t('Deleted successfully')); + message.success(t('Deleted successfully')); await refreshRoutesAfterMutation(); }, - [ctx.message, desktopRoutesResource, layout.uid, refreshRoutesAfterMutation, t], + [desktopRoutesResource, message, portalUid, refreshRoutesAfterMutation, t], ); + const selectedRouteIds = useMemo( () => selectedRowKeys.filter((key): key is number | string => typeof key === 'number' || typeof key === 'string'), [selectedRowKeys], ); const hasSelectedRoutes = selectedRouteIds.length > 0; - const visibleRoutes = useMemo(() => filterManagedRoutes(routes), [routes]); - const filteredRoutes = useMemo( () => filterRoutesByKeyword(visibleRoutes, filterValues.keyword || '', t), - [filterValues.keyword, visibleRoutes, t], + [filterValues.keyword, t, visibleRoutes], ); const updateSelectedRoutes = useCallback( @@ -813,58 +694,31 @@ function RoutesTable({ layout }: { layout: RouteLayoutConfig }) { for (const routeId of selectedRouteIds) { await desktopRoutesResource.update({ filterByTk: routeId, - layout: layout.uid, + portal: portalUid, values, }); } - ctx.message?.success?.(t('Updated successfully')); + message.success(t('Updated successfully')); await refreshRoutesAfterMutation(); }, - [ctx.message, desktopRoutesResource, layout.uid, refreshRoutesAfterMutation, selectedRouteIds, t], + [desktopRoutesResource, message, portalUid, refreshRoutesAfterMutation, selectedRouteIds, t], ); - const deleteSelectedRoutes = useCallback(async () => { - if (!selectedRouteIds.length) { - return; - } - await desktopRoutesResource.destroy({ - filterByTk: selectedRouteIds, - layout: layout.uid, - }); - ctx.message?.success?.(t('Deleted successfully')); - await refreshRoutesAfterMutation(); - }, [ctx.message, desktopRoutesResource, layout.uid, refreshRoutesAfterMutation, selectedRouteIds, t]); - - const openDeleteRouteConfirm = useCallback( - (route: NocoBaseDesktopRoute) => { + const openDeleteConfirm = useCallback( + (filterByTk: Array | number | string, batch = false) => { modal.confirm({ cancelText: t('Cancel'), content: t('Are you sure you want to delete it?'), okText: t('Delete'), async onOk() { - await handleDelete(route); + await handleDelete(filterByTk); }, - title: t('Delete route'), + title: batch ? t('Delete routes') : t('Delete route'), }); }, [handleDelete, modal, t], ); - const openDeleteSelectedRoutesConfirm = useCallback(() => { - if (!hasSelectedRoutes) { - return; - } - modal.confirm({ - cancelText: t('Cancel'), - content: t('Are you sure you want to delete it?'), - okText: t('Delete'), - async onOk() { - await deleteSelectedRoutes(); - }, - title: t('Delete routes'), - }); - }, [deleteSelectedRoutes, hasSelectedRoutes, modal, t]); - const columns = useMemo>( () => [ { @@ -895,15 +749,12 @@ function RoutesTable({ layout }: { layout: RouteLayoutConfig }) { title: t('Path'), width: 320, render: (_value, route) => { - const path = getRouteAccessPath(route, layout, routes); - if (!path) { - return null; - } - return ( + const path = getRouteAccessPath(route, portal, routes); + return path ? ( {path} - ); + ) : null; }, }, { @@ -912,15 +763,14 @@ function RoutesTable({ layout }: { layout: RouteLayoutConfig }) { width: 260, render: (_value, route) => { const routeTitle = getRouteTitle(route, t); - const accessPath = getRouteAccessPath(route, layout, routes); - const accessHref = accessPath ? getUiLayoutRouteUrl(ctx.app, accessPath) : ''; - const addChildDisabled = !canRouteHaveChildren(route); + const accessPath = getRouteAccessPath(route, portal, routes); + const accessHref = accessPath ? getMultiPortalRouteUrl(ctx.app, accessPath, portal.portalType) : ''; return ( @@ -1006,7 +847,7 @@ function RoutesTable({ layout }: { layout: RouteLayoutConfig }) { > {t('Show in menu')} - @@ -1014,50 +855,28 @@ function RoutesTable({ layout }: { layout: RouteLayoutConfig }) { columns={columns} dataSource={filteredRoutes} - expandable={{ - rowExpandable: (route) => !!route.children?.length, - }} + expandable={{ rowExpandable: (route) => !!route.children?.length }} loading={loading} - locale={{ - emptyText: t('No routes in {{layout}}', { layout: t(layout.label) }), - }} - pagination={{ - pageSize: 20, - total: filteredRoutes.length, - }} - rowSelection={{ - onChange: setSelectedRowKeys, - selectedRowKeys, - }} + locale={{ emptyText: t('No routes') }} + pagination={{ pageSize: 20, total: filteredRoutes.length }} rowKey={(route) => route.id ?? String(route.schemaUid)} - /> - ); } -type RoutesPageProps = { - layoutKey?: RouteLayoutConfig['key']; -}; +export default function PortalRoutesDrawer({ portal }: { portal: MultiPortalRecord }) { + const t = useT(); + const view = useFlowView(); + const { token } = theme.useToken(); -const RoutesPage: React.FC = ({ layoutKey = 'desktop' }) => { - const layout = routeLayouts.find((item) => item.key === layoutKey) ?? routeLayouts[0]; return ( - - - +
+ {view.Header ? : null} +
+ +
+
); -}; - -export const MobileRoutesPage: React.FC = () => ; - -export default RoutesPage; +} diff --git a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/permissions/MultiPortalPermissionsTab.tsx b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/permissions/MultiPortalPermissionsTab.tsx index 5b46e0856fa..d512fc75e97 100644 --- a/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/permissions/MultiPortalPermissionsTab.tsx +++ b/packages/plugins/@nocobase/plugin-multi-portal/src/client-v2/permissions/MultiPortalPermissionsTab.tsx @@ -13,11 +13,13 @@ import { useMemoizedFn, useRequest } from 'ahooks'; import { Button, Checkbox, Drawer, Input, Space, Typography, theme } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import React, { useEffect, useMemo, useState } from 'react'; +import { isDefaultLayoutMultiPortalUid } from '../../constants'; import { useT } from '../locale'; interface Role { name: string; title: string; + allowNewMenu?: boolean; allowNewMultiPortal?: boolean; } @@ -30,6 +32,7 @@ interface PermissionTabProps { interface MultiPortalRecord { uid: string; title?: string; + portalType?: string | null; } interface MultiPortalPayload { @@ -84,7 +87,16 @@ interface RoleMultiPortalsResource { } interface RolesResource { - update: (params: { filterByTk: string; values: Pick }) => Promise; + update: (params: { + filterByTk: string; + values: Partial>; + }) => Promise; +} + +interface RoleDesktopRoutesResource { + list: (params?: Record) => Promise; + add: (params: { values: number[] }) => Promise; + remove: (params: { values: number[] }) => Promise; } interface RoleMultiPortalDesktopRoutesResource { @@ -153,6 +165,17 @@ function toRoutePermissionData(responseData: unknown): MultiPortalRoutePermissio return Array.isArray(payload.data) ? payload.data : []; } +function toLayoutRoutePermissionIds(responseData: unknown): number[] { + if (!responseData || typeof responseData !== 'object') { + return []; + } + const payload = responseData as { data?: Array<{ id?: number }> }; + if (!Array.isArray(payload.data)) { + return []; + } + return payload.data.map((item) => item.id).filter((id): id is number => typeof id === 'number'); +} + function toRoutePolicyData(responseData: unknown): MultiPortalRoutePolicyRecord | undefined { if (!responseData || typeof responseData !== 'object') { return undefined; @@ -304,6 +327,14 @@ function getRoutePermissionChanges(input: { }; } +function hasLayoutRoutePermissions(portal: MultiPortalRecord | undefined) { + return isDefaultLayoutMultiPortalUid(portal?.uid); +} + +function supportsRoutePermissions(portal: MultiPortalRecord | undefined) { + return portal?.portalType === 'no-code'; +} + export default function MultiPortalPermissionsTab(props: PermissionTabProps) { const ctx = useFlowContext(); const t = useT(); @@ -330,6 +361,11 @@ export default function MultiPortalPermissionsTab(props: PermissionTabProps) { () => ctx.api.resource('rolesMultiPortalRoutePolicies') as unknown as RoleMultiPortalRoutePoliciesResource, [ctx.api], ); + const roleDesktopRoutesResource = useMemo( + () => + role ? (ctx.api.resource('roles.desktopRoutes', role.name) as unknown as RoleDesktopRoutesResource) : undefined, + [ctx.api, role], + ); const portalService = useRequest( async () => { @@ -345,7 +381,6 @@ export default function MultiPortalPermissionsTab(props: PermissionTabProps) { refreshDeps: [active], }, ); - const rolePortalService = useRequest( async () => { if (!roleMultiPortalsResource) { @@ -371,12 +406,14 @@ export default function MultiPortalPermissionsTab(props: PermissionTabProps) { [portalService.data, selectedPortalUid], ); const selectedPortalTitle = selectedPortal ? translateTitle(selectedPortal.title, t) : ''; + const selectedPortalUsesLayoutPermissions = hasLayoutRoutePermissions(selectedPortal); + const selectedPortalSupportsRoutes = supportsRoutePermissions(selectedPortal); const drawerTitle = selectedPortal ? t('Configure routes permissions for {{portal}}', { portal: selectedPortalTitle }) : t('Routes permissions'); const routeService = useRequest( async () => { - if (!selectedPortalUid) { + if (!selectedPortalUid || !selectedPortalSupportsRoutes) { return []; } const response = await ctx.api.request({ @@ -390,8 +427,8 @@ export default function MultiPortalPermissionsTab(props: PermissionTabProps) { return toDesktopRoutePayload(response?.data).data ?? []; }, { - ready: active && !!selectedPortalUid, - refreshDeps: [active, selectedPortalUid], + ready: active && !!selectedPortalUid && selectedPortalSupportsRoutes, + refreshDeps: [active, selectedPortalUid, selectedPortalSupportsRoutes], }, ); const routeItems = useMemo(() => toRouteItems(routeService.data), [routeService.data]); @@ -418,9 +455,21 @@ export default function MultiPortalPermissionsTab(props: PermissionTabProps) { : t('No routes'); const roleRoutePermissionService = useRequest( async () => { - if (!role || !selectedPortalUid) { + if (!role || !selectedPortalUid || !selectedPortalSupportsRoutes) { return []; } + if (selectedPortalUsesLayoutPermissions) { + if (!roleDesktopRoutesResource) { + return []; + } + const response = await roleDesktopRoutesResource.list({ + paginate: false, + filter: { + id: allRouteIds, + }, + }); + return toLayoutRoutePermissionIds(response?.data); + } const response = await roleRoutePermissionsResource.list({ paginate: false, filter: { @@ -433,8 +482,15 @@ export default function MultiPortalPermissionsTab(props: PermissionTabProps) { .filter((id): id is number => typeof id === 'number'); }, { - ready: active && !!role && !!selectedPortalUid, - refreshDeps: [active, role?.name, selectedPortalUid], + ready: active && !!role && !!selectedPortalUid && selectedPortalSupportsRoutes, + refreshDeps: [ + active, + allRouteIds, + role?.name, + selectedPortalSupportsRoutes, + selectedPortalUid, + selectedPortalUsesLayoutPermissions, + ], onSuccess(data) { setSelectedRouteIds(data); }, @@ -442,7 +498,7 @@ export default function MultiPortalPermissionsTab(props: PermissionTabProps) { ); const roleRoutePolicyService = useRequest( async () => { - if (!role || !selectedPortalUid) { + if (!role || !selectedPortalUid || selectedPortalUsesLayoutPermissions || !selectedPortalSupportsRoutes) { return undefined; } const response = await roleRoutePoliciesResource.list({ @@ -455,8 +511,15 @@ export default function MultiPortalPermissionsTab(props: PermissionTabProps) { return toRoutePolicyData(response?.data); }, { - ready: active && !!role && !!selectedPortalUid, - refreshDeps: [active, role?.name, selectedPortalUid], + ready: + active && !!role && !!selectedPortalUid && selectedPortalSupportsRoutes && !selectedPortalUsesLayoutPermissions, + refreshDeps: [ + active, + role?.name, + selectedPortalSupportsRoutes, + selectedPortalUid, + selectedPortalUsesLayoutPermissions, + ], onSuccess(data) { setRouteDefaultPolicy(data); setRouteDefaultPolicyChecked(!!data?.allowNewMenu); @@ -511,21 +574,23 @@ export default function MultiPortalPermissionsTab(props: PermissionTabProps) { await savePortalAccess(nextSelectedUids); }); - const updateRoleDefaults = useMemoizedFn(async (values: Pick) => { - if (!role) { - return; - } - try { - await (ctx.api.resource('roles') as unknown as RolesResource).update({ - filterByTk: role.name, - values, - }); - } catch { - return; - } - props.onRoleChange?.({ ...role, ...values }); - ctx.message.success(t('Saved successfully')); - }); + const updateRoleDefaults = useMemoizedFn( + async (values: Partial>) => { + if (!role) { + return; + } + try { + await (ctx.api.resource('roles') as unknown as RolesResource).update({ + filterByTk: role.name, + values, + }); + } catch { + return; + } + props.onRoleChange?.({ ...role, ...values }); + ctx.message.success(t('Saved successfully')); + }, + ); const columns = useMemo>( () => [ @@ -538,6 +603,9 @@ export default function MultiPortalPermissionsTab(props: PermissionTabProps) { dataIndex: 'accessible', title: t('Allow access'), render: (_, portal) => { + if (hasLayoutRoutePermissions(portal)) { + return {t('Managed by layout permissions')}; + } const portalTitle = translateTitle(portal.title, t); return ( { + if (!supportsRoutePermissions(portal)) { + return -; + } const portalTitle = translateTitle(portal.title, t); return ( , + Dropdown: (props: DropdownProps) => { + holder.dropdownProps = props; + return
{props.children}
; + }, + Space: ({ children }: { children?: React.ReactNode }) =>
{children}
, + theme: { + useToken: () => ({ token: {} }), + }, +})); + +import { ExecutionsDropdown } from '../ExecutionsDropdown'; + +describe('ExecutionsDropdown', () => { + it('uses the runtime-derived path when switching executions', () => { + render(); + + expect(holder.dropdownProps).not.toBeNull(); + holder.dropdownProps?.menu.onClick({ key: '2' }); + + expect(holder.navigate).toHaveBeenCalledWith('/workflow/executions/2'); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/constants.ts b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/constants.ts index 34880011d51..9e31dfdc849 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/constants.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/constants.ts @@ -7,24 +7,20 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -// The canvas page is registered directly under `admin` (not `admin.settings`), so it renders in the admin content area -// without the settings left menu. `admin.workflow.*` is a shared namespace so sibling pages (e.g. executions at -// `/admin/workflow/executions/:id`) can line up under the same prefix, mirroring v1's `admin.workflow.workflows.id` -// route. -export const WORKFLOW_CANVAS_ROUTE_NAME = 'admin.workflow.workflows.id'; -export const WORKFLOW_CANVAS_ROUTE_PATH = '/admin/workflow/workflows/:id'; +// Full-width workflow details are siblings of the normal Settings layout. The `settingsDetails.*` namespace is owned +// only by the standalone Client V2 Settings router, so these pages keep the compact Settings header without the sidebar. +export const WORKFLOW_CANVAS_ROUTE_NAME = 'settingsDetails.workflow.workflows.id'; +export const WORKFLOW_CANVAS_ROUTE_PATH = '/settings/workflow/workflows/:id'; export function getWorkflowCanvasPath(id: string | number) { - return `/admin/workflow/workflows/${id}`; + return `/settings/workflow/workflows/${id}`; } -// Execution detail page, a sibling of the canvas under the same `admin.workflow` namespace — mirrors v1's -// `admin.workflow.executions.id` route. -export const WORKFLOW_EXECUTION_ROUTE_NAME = 'admin.workflow.executions.id'; -export const WORKFLOW_EXECUTION_ROUTE_PATH = '/admin/workflow/executions/:id'; +export const WORKFLOW_EXECUTION_ROUTE_NAME = 'settingsDetails.workflow.executions.id'; +export const WORKFLOW_EXECUTION_ROUTE_PATH = '/settings/workflow/executions/:id'; export function getWorkflowExecutionPath(id: string | number) { - return `/admin/workflow/executions/${id}`; + return `/settings/workflow/executions/${id}`; } export const WORKFLOW_TASKS_ROUTE_NAME = 'admin.workflow.tasks'; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/hooks/__tests__/useWorkflowRuntimePaths.test.ts b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/hooks/__tests__/useWorkflowRuntimePaths.test.ts index 93c511ba6cd..381679dd18e 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/hooks/__tests__/useWorkflowRuntimePaths.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/hooks/__tests__/useWorkflowRuntimePaths.test.ts @@ -12,10 +12,18 @@ import { renderHook } from '@testing-library/react'; const holder = vi.hoisted(() => ({ runtime: 'legacy' as 'legacy' | 'modern', + routeName: 'admin.settings.', + routeRoot: '/admin/settings/', })); vi.mock('@nocobase/client-v2', () => ({ getRouteRuntimeVersion: () => holder.runtime, + useApp: () => ({ + pluginSettingsManager: { + getRouteName: () => holder.routeName, + getRoutePath: () => holder.routeRoot, + }, + }), })); import { @@ -28,6 +36,8 @@ import { describe('useWorkflowRuntimePaths', () => { it('maps legacy runtime to legacy workflow routes', () => { holder.runtime = 'legacy'; + holder.routeName = 'admin.settings.'; + holder.routeRoot = '/admin/settings/'; expect(isWorkflowV2Runtime()).toBe(false); expect(getWorkflowCanvasRuntimePath(123)).toBe('/admin/settings/workflow/workflows/123'); @@ -36,18 +46,33 @@ describe('useWorkflowRuntimePaths', () => { it('maps modern runtime to modern workflow routes', () => { holder.runtime = 'modern'; + holder.routeName = 'admin.settings.'; + holder.routeRoot = '/admin/settings/'; expect(isWorkflowV2Runtime()).toBe(true); - expect(getWorkflowCanvasRuntimePath(123)).toBe('/admin/workflow/workflows/123'); - expect(getWorkflowExecutionRuntimePath(456)).toBe('/admin/workflow/executions/456'); + expect(getWorkflowCanvasRuntimePath(123)).toBe('/settings/workflow/workflows/123'); + expect(getWorkflowExecutionRuntimePath(456)).toBe('/settings/workflow/executions/456'); }); it('exposes memoized route helpers through the hook', () => { holder.runtime = 'modern'; + holder.routeName = 'admin.settings.'; + holder.routeRoot = '/admin/settings/'; const { result } = renderHook(() => useWorkflowRuntimePaths()); expect(result.current.isV2Runtime).toBe(true); - expect(result.current.getWorkflowCanvasPath(123)).toBe('/admin/workflow/workflows/123'); - expect(result.current.getWorkflowExecutionPath(456)).toBe('/admin/workflow/executions/456'); + expect(result.current.getWorkflowCanvasPath(123)).toBe('/settings/workflow/workflows/123'); + expect(result.current.getWorkflowExecutionPath(456)).toBe('/settings/workflow/executions/456'); + }); + + it('derives scoped Settings detail routes from the current manager root', () => { + holder.runtime = 'legacy'; + holder.routeName = 'settings.'; + holder.routeRoot = '/'; + + const { result } = renderHook(() => useWorkflowRuntimePaths()); + expect(result.current.isV2Runtime).toBe(true); + expect(result.current.getWorkflowCanvasPath(123)).toBe('/workflow/workflows/123'); + expect(result.current.getWorkflowExecutionPath(456)).toBe('/workflow/executions/456'); }); }); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/hooks/useWorkflowRuntimePaths.ts b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/hooks/useWorkflowRuntimePaths.ts index 3f0d30aa13d..158d6558275 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/hooks/useWorkflowRuntimePaths.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/hooks/useWorkflowRuntimePaths.ts @@ -8,7 +8,7 @@ */ import { useMemoizedFn } from 'ahooks'; -import { getRouteRuntimeVersion } from '@nocobase/client-v2'; +import { getRouteRuntimeVersion, useApp } from '@nocobase/client-v2'; import { getWorkflowCanvasPath, getWorkflowExecutionPath } from '../constants'; export function isWorkflowV2Runtime() { return getRouteRuntimeVersion() === 'modern'; @@ -29,11 +29,27 @@ export function getWorkflowExecutionRuntimePath(id: string | number) { } export function useWorkflowRuntimePaths() { - const getCanvasPath = useMemoizedFn((id: string | number) => getWorkflowCanvasRuntimePath(id)); - const getExecutionPath = useMemoizedFn((id: string | number) => getWorkflowExecutionRuntimePath(id)); + const app = useApp(); + const isStandaloneSettings = app.pluginSettingsManager.getRouteName('') === 'settings.'; + const settingsRoot = app.pluginSettingsManager.getRoutePath('').replace(/\/+$/, ''); + const isV2Runtime = isStandaloneSettings || isWorkflowV2Runtime(); + const getCanvasPath = useMemoizedFn((id: string | number) => + isStandaloneSettings + ? `${settingsRoot}/workflow/workflows/${id}` + : isV2Runtime + ? getWorkflowCanvasPath(id) + : `/admin/settings/workflow/workflows/${id}`, + ); + const getExecutionPath = useMemoizedFn((id: string | number) => + isStandaloneSettings + ? `${settingsRoot}/workflow/executions/${id}` + : isV2Runtime + ? getWorkflowExecutionPath(id) + : `/admin/settings/workflow/executions/${id}`, + ); return { - isV2Runtime: isWorkflowV2Runtime(), + isV2Runtime, getWorkflowCanvasPath: getCanvasPath, getWorkflowExecutionPath: getExecutionPath, }; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/legacySettingsRedirect.ts b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/legacySettingsRedirect.ts new file mode 100644 index 00000000000..48719ad4471 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/legacySettingsRedirect.ts @@ -0,0 +1,24 @@ +/** + * 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 LegacyWorkflowLocation = { + pathname: string; + search: string; + hash: string; +}; + +export function buildLegacyWorkflowSettingsTarget(rootPublicPath: string, location: LegacyWorkflowLocation) { + const root = rootPublicPath.replace(/\/+$/, ''); + const appScope = /\/(?:apps|_app)\/[^/]+(?=\/admin\/workflow(?:\/|$))/.exec(location.pathname)?.[0] || ''; + const routePath = location.pathname.replace(/^.*?\/admin\/workflow(?=\/|$)/, '/settings/workflow'); + const scopedRoutePath = routePath.replace(/^\/settings(?=\/|$)/, ''); + const documentPath = appScope ? `/settings${appScope}${scopedRoutePath}` : routePath; + + return `${root}${documentPath}${location.search}${location.hash}`; +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/WorkflowPane.test.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/WorkflowPane.test.tsx index d40de02b720..e7c361454d5 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/WorkflowPane.test.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/WorkflowPane.test.tsx @@ -38,6 +38,7 @@ vi.mock('../../locale', () => ({ vi.mock('@nocobase/client-v2', () => ({ DEFAULT_PAGE_SIZE: 20, getRouteRuntimeVersion: () => 'modern', + useApp: () => holder.ctx.app, FormSubmitActionModel: { registerFlow: vi.fn(), }, @@ -117,7 +118,11 @@ function makeCtx(resourceMap: Record) { return { api: { resource: (name: string) => resourceMap[name] }, viewer: { drawer: vi.fn(), dialog: vi.fn() }, - app: { name: 'main', pm: { get: () => mockPlugin } }, + app: { + name: 'main', + pm: { get: () => mockPlugin }, + pluginSettingsManager: { getRoutePath: () => '/admin/settings/workflow' }, + }, }; } diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/plugin.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/plugin.tsx index a93874350a9..8443a7b3f6b 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/plugin.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/plugin.tsx @@ -7,9 +7,10 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { Plugin } from '@nocobase/client-v2'; +import { Plugin, stripModernClientPrefix, useApp } from '@nocobase/client-v2'; import { Registry } from '@nocobase/utils/client'; -import type { ReactNode } from 'react'; +import React, { type ReactNode, useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; import { NAMESPACE } from './locale'; import { WORKFLOW_CANVAS_ROUTE_NAME, @@ -24,6 +25,7 @@ import { import type { TaskTypeOptions } from './taskCenter'; import type { Instruction } from './canvas/Instruction'; import type { Trigger } from './triggers'; +import { buildLegacyWorkflowSettingsTarget } from './legacySettingsRedirect'; import './models/triggerWorkflows'; // Core node instructions — one file per node under `nodes/`, mirroring v1's `client/nodes/` layout. Each @@ -90,6 +92,19 @@ export type WorkflowNoticeProvider = WorkflowNoticeProviderFunction | WorkflowNo const tpl = (key: string) => `{{t("${key}", { ns: "${NAMESPACE}" })}}`; +function LegacyWorkflowSettingsRedirect() { + const app = useApp(); + const location = useLocation(); + const rootPublicPath = stripModernClientPrefix(app.getPublicPath()).replace(/\/+$/, ''); + const targetPath = buildLegacyWorkflowSettingsTarget(rootPublicPath, location); + + useEffect(() => { + window.location.replace(targetPath); + }, [targetPath]); + + return app.renderComponent('AppSpin'); +} + /** Core instruction groups, in v1 display order. */ const coreInstructionGroups: InstructionGroup[] = [ { key: 'control', label: tpl('Control') }, @@ -326,22 +341,26 @@ export class PluginWorkflowClientV2 extends Plugin { }); } - // The canvas page is registered directly under `admin` (not `admin.settings`), so it renders in the admin content - // area without the settings left menu — mirroring v1's `router.add('admin.workflow.workflows.id', ...)`. private registerCanvasRoute() { this.app.router.add(WORKFLOW_CANVAS_ROUTE_NAME, { path: WORKFLOW_CANVAS_ROUTE_PATH, componentLoader: () => import('./pages/WorkflowCanvasPage'), }); + this.app.router.add('admin.workflow.workflows.id', { + path: '/admin/workflow/workflows/:id', + Component: LegacyWorkflowSettingsRedirect, + }); } - // The execution detail page, a sibling of the canvas under the same `admin.workflow` namespace — mirrors v1's - // `admin.workflow.executions.id`. private registerExecutionRoute() { this.app.router.add(WORKFLOW_EXECUTION_ROUTE_NAME, { path: WORKFLOW_EXECUTION_ROUTE_PATH, componentLoader: () => import('./pages/ExecutionViewPage'), }); + this.app.router.add('admin.workflow.executions.id', { + path: '/admin/workflow/executions/:id', + Component: LegacyWorkflowSettingsRedirect, + }); } private registerTaskCenterRoutes() { diff --git a/packages/presets/nocobase/package.json b/packages/presets/nocobase/package.json index 9cbebdd2bae..37cfc1b8851 100644 --- a/packages/presets/nocobase/package.json +++ b/packages/presets/nocobase/package.json @@ -170,6 +170,7 @@ "@nocobase/plugin-ui-schema-storage", "@nocobase/plugin-user-data-sync", "@nocobase/plugin-users", + "@nocobase/plugin-multi-portal", "@nocobase/plugin-verification", "@nocobase/plugin-workflow", "@nocobase/plugin-workflow-action-trigger", diff --git a/packages/presets/nocobase/src/server/__tests__/multiPortal.test.ts b/packages/presets/nocobase/src/server/__tests__/multiPortal.test.ts index fa4a6d0441a..fa7218b3f5f 100644 --- a/packages/presets/nocobase/src/server/__tests__/multiPortal.test.ts +++ b/packages/presets/nocobase/src/server/__tests__/multiPortal.test.ts @@ -12,20 +12,31 @@ import path from 'node:path'; const MULTI_PORTAL_PACKAGE = '@nocobase/plugin-multi-portal'; const UI_LAYOUT_PACKAGE = '@nocobase/plugin-ui-layout'; +const USERS_PACKAGE = '@nocobase/plugin-users'; function readJson(relativePath: string) { return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), relativePath), 'utf8')); } describe('plugin-multi-portal preset boundary', () => { - it('should not include Multi-Portal in the default NocoBase preset', () => { + it('includes Multi-Portal as an open built-in after UI Layout and Users', () => { const packageJson = readJson('packages/presets/nocobase/package.json'); const ossPluginRoot = path.resolve(process.cwd(), 'packages/plugins/@nocobase/plugin-multi-portal'); + const multiPortalPackageJson = readJson('packages/plugins/@nocobase/plugin-multi-portal/package.json'); - expect(fs.existsSync(ossPluginRoot)).toBe(false); - expect(packageJson.dependencies).not.toHaveProperty(MULTI_PORTAL_PACKAGE); - expect(packageJson.builtIn).not.toContain(MULTI_PORTAL_PACKAGE); + expect(fs.existsSync(ossPluginRoot)).toBe(true); + expect(packageJson.dependencies).toHaveProperty(MULTI_PORTAL_PACKAGE); + expect(packageJson.builtIn).toContain(MULTI_PORTAL_PACKAGE); expect(packageJson.dependencies).toHaveProperty(UI_LAYOUT_PACKAGE); expect(packageJson.builtIn).toContain(UI_LAYOUT_PACKAGE); + expect(packageJson.dependencies).toHaveProperty(USERS_PACKAGE); + expect(packageJson.builtIn).toContain(USERS_PACKAGE); + expect(packageJson.builtIn.indexOf(MULTI_PORTAL_PACKAGE)).toBeGreaterThan( + packageJson.builtIn.indexOf(UI_LAYOUT_PACKAGE), + ); + expect(packageJson.builtIn.indexOf(MULTI_PORTAL_PACKAGE)).toBeGreaterThan( + packageJson.builtIn.indexOf(USERS_PACKAGE), + ); + expect(multiPortalPackageJson.nocobase).not.toHaveProperty('editionLevel'); }); });