From dac4998ebb600dcb4e540cdd582810761d9579d4 Mon Sep 17 00:00:00 2001 From: Jesse <15653378+squarezw@user.noreply.gitee.com> Date: Tue, 4 Aug 2026 18:01:06 +0800 Subject: [PATCH] feat(apps,skills): show what you may actually do, and name the creator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Needs ragent-service (auto-bound tools in the bind response, `SkillResponse.author`, owner-or-super on the app write endpoints). ## Buttons now match what the server will allow Two places showed actions to people who cannot perform them. Both fail in the same direction: the UI says "you can change this", then the server says 403 after the user commits to the action. That order reads as a broken system rather than as a permission boundary. - **Skill list**: 编辑 / 删除 rendered unconditionally. A dept admin saw both buttons lit on everyone's skills. Now: editable → 编辑 + 删除; not editable → **查看**, since they *can* read the detail page — hiding the button entirely would suggest the page is off limits. - **Skill detail**: edit rights were `canReview || owner`, and `canReview` used `checkTenantAdmin` **without tenant scoping** — a tenant admin from a different tenant could edit and save here, while the backend's `is_reviewer` requires the same tenant. The UI was the looser side. Conversely, three places were *stricter* than the backend and locked owners out of their own apps: the list/card edit+delete buttons, the 绑定工具 / per-tool actions, and the role editor — the backend has allowed owner-or-super on Agent.md all along. `lib/appPermissions.ts` and `lib/skillPermissions.ts` are the single frontend implementations, each mirroring one backend rule. Both carry the same warning: they only decide *display*. Hiding a button is not a security boundary — anyone can call the API directly. The enforcement lives on the server. Role booleans are passed in rather than imported from `clientPermissions`, so these stay pure functions the test runner can load. (Importing it broke the suite: the runner resolves neither the `@/` alias nor extensionless relative TS.) ## Skill list: 创建者 column Shows the creator (nickname → username). Empty when the account was deleted: the skill still exists and shouldn't vanish from the list because its author closed their account, so the cell shows — rather than going blank, which reads as a rendering fault. Header cells for the three narrow columns are `whitespace-nowrap` — at narrow widths two- and three-character Chinese headers were stacking one character per line. ## Binding a skill reports the tools it brought along The backend auto-binds a skill's `requires.tools`; the toast names them, and the tools list is invalidated so the count updates. Without invalidation the user sees an unchanged tools panel and concludes the auto-bind didn't work. Warnings (tool missing or disabled → the skill still won't inject) surface as separate warning toasts, since they aren't part of the success. ## Verified in the browser, per role - Plain user owning an app: edit/delete on their own row, 绑定工具 / 绑定 Skill / editable role on their own app, and on someone else's app only 返回 - Dept admin (the reported case): 编辑 + 删除 on their own skill, 查看 on the other four 229 tests pass (+13). Co-Authored-By: Claude Opus 5 (1M context) --- app/apps/[id]/page.tsx | 10 ++-- app/apps/components/AgentMdEditor.tsx | 13 +++++- app/apps/components/AppSkillsSection.tsx | 14 +++++- app/apps/page.tsx | 5 +- app/skills/[id]/page.tsx | 15 +++++- app/skills/page.tsx | 58 +++++++++++++++++------- hooks/useAppSkills.ts | 22 ++++++++- lib/appPermissions.ts | 35 ++++++++++++++ lib/skillPermissions.ts | 51 +++++++++++++++++++++ messages/en/common.json | 3 +- messages/en/skills.json | 4 +- messages/zh-CN/common.json | 3 +- messages/zh-CN/skills.json | 4 +- test/appPermissions.test.ts | 29 ++++++++++++ test/skillPermissions.test.ts | 53 ++++++++++++++++++++++ types/skill.ts | 2 + 16 files changed, 288 insertions(+), 33 deletions(-) create mode 100644 lib/appPermissions.ts create mode 100644 lib/skillPermissions.ts create mode 100644 test/appPermissions.test.ts create mode 100644 test/skillPermissions.test.ts diff --git a/app/apps/[id]/page.tsx b/app/apps/[id]/page.tsx index 82413e9..b7b0f98 100644 --- a/app/apps/[id]/page.tsx +++ b/app/apps/[id]/page.tsx @@ -55,6 +55,7 @@ import ReviewLogDialog from "@/components/ReviewLogDialog"; import ReviewRejectDialog from "@/components/ReviewRejectDialog"; import { useCurrentUser } from "@/hooks/useCurrentUser"; import { checkSuperAdmin, checkTenantAdmin } from "@/lib/clientPermissions"; +import { canEditApp } from "@/lib/appPermissions"; import { isSelfReview } from "@/lib/reviewQueue"; import { REVIEW_STATUSES, appStatusBadge, type ReviewStatus } from "@/lib/reviewStatus"; import axios from "@/lib/axios"; @@ -248,6 +249,8 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string // published 不出徽标(正常终态且无出口动作,规则见 lib/reviewStatus.ts) const statusBadge = appStatusBadge(appInfo.status); const canReview = checkSuperAdmin(user) || checkTenantAdmin(user); + // 绑工具/改配置 = owner 或超管(与后端 require_app_owner_or_super 同规矩) + const canEditThisApp = canEditApp(appInfo, user, checkSuperAdmin(user)); // 审核人不能审自己提交的对象(超管除外,后端违者 403) const selfReview = isSelfReview(user?.id, appInfo.user_id, checkSuperAdmin(user)); // 状态徽标并入基本信息首行;动作/提示另起一行,且只在真有内容时渲染 @@ -476,7 +479,7 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string
{t("boundTools", { count: appTools.length })} - {checkSuperAdmin(user) && ( + {canEditThisApp && ( - + {/* 没有写权限的人不该看到「编辑」「删除」——原先两个按钮无条件渲染, + 点下去才被后端 403 拦住。界面先说"你可以改"、动手后再说"不行", + 用户会以为是系统坏了而不是自己没权限。 + 但详情页他是能看的,所以按钮换成「查看」而不是整个消失。 */} + {canEditSkill(skill, user, isSuperAdmin, isTenantAdmin) ? ( + <> + + + + ) : ( + + )}
diff --git a/hooks/useAppSkills.ts b/hooks/useAppSkills.ts index be99cd8..e4b7956 100644 --- a/hooks/useAppSkills.ts +++ b/hooks/useAppSkills.ts @@ -1,4 +1,4 @@ -import useSWR from "swr"; +import useSWR, { useSWRConfig } from "swr"; import { useTranslations } from "next-intl"; import axios from "@/lib/axios"; import { toast } from "sonner"; @@ -46,6 +46,7 @@ const fetcher = async (url: string) => { export const useAppSkills = (appId: number | null) => { const t = useTranslations("skills"); const invalidateDiagnostics = useInvalidateAppSkillDiagnostics(); + const { mutate: mutateKey } = useSWRConfig(); const url = appId ? `/api/v1/apps/${appId}/skills` : null; const { data, error, isLoading, mutate } = useSWR(url, fetcher, { @@ -61,7 +62,24 @@ export const useAppSkills = (appId: number | null) => { const res = await axios.post(`/api/v1/apps/${appId}/skills`, { skill_id: skillId, }); - toast.success(t("bindSuccess")); + + // 后端会把 skill 声明的 requires.tools 自动补绑到这个应用上(否则整份 skill + // 静默不注入)。这是平台替用户做的**授权动作**,必须说出来 —— 悄悄多给一个 + // 工具,比让用户自己去补更糟。 + const autoBound: string[] = res.data?.data?.auto_bound_tools ?? []; + const warnings: string[] = res.data?.data?.warnings ?? []; + + if (autoBound.length > 0) { + toast.success(t("bindSuccessWithTools", { tools: autoBound.join("、") })); + // 工具区是另一个 hook(useAppTools,key 前缀不同),不失效它的话用户会看到 + // 一个"工具数没变"的界面,然后以为自动绑定没生效 + mutateKey((key) => typeof key === "string" && key.includes(`/apps/${appId}/tools`)); + } else { + toast.success(t("bindSuccess")); + } + // 警告分开提示:它们说的是"这份 skill 现在不会生效",和成功不是一回事 + warnings.forEach((w) => toast.warning(w)); + mutate(); invalidateDiagnostics(appId); return res.data; diff --git a/lib/appPermissions.ts b/lib/appPermissions.ts new file mode 100644 index 0000000..3e69ced --- /dev/null +++ b/lib/appPermissions.ts @@ -0,0 +1,35 @@ +/** + * 数字员工的写权限 —— 前端**唯一实现**,与后端 app/utils/app_permissions.py 同一条规矩。 + * + * 规矩:改一个 app 的任何东西(配置 / 角色设定 / 绑工具 / 绑 skill)= **owner 或超管**。 + * 其他人只读。 + * + * 为什么单独抽出来:这个判断会出现在列表、卡片、详情页、各个绑定区,散着写就会长成 + * 好几个样子。2026-08-04 之前后端就是这样——同一条规矩在三个文件里三种写法,其中一处 + * 压根没检查。前端同理,只是它的错法更隐蔽:按钮该显示的没显示(用户以为自己没权限), + * 或者不该显示的显示了(点下去才吃 403)。 + * + * ⚠️ 这里只管**显示**。真正的边界在后端——前端藏起按钮不构成任何安全保证, + * 谁都可以直接调接口。改这里时不要以为自己在做安全,安全那半边在服务端。 + */ + +interface AppLike { + user_id?: number | null; +} + +interface UserLike { + id?: number | null; +} + +/** 能改这个 app 吗(owner 或超管) */ +export function canEditApp( + app: AppLike | null | undefined, + user: UserLike | null | undefined, + isSuperAdmin: boolean +): boolean { + if (isSuperAdmin) return true; + // 两边都要有 id 才谈得上"是同一个人":任一为空时按无权限处理, + // 否则 undefined === undefined 会让未登录用户看起来像每个无主应用的 owner + if (app?.user_id == null || user?.id == null) return false; + return app.user_id === user.id; +} diff --git a/lib/skillPermissions.ts b/lib/skillPermissions.ts new file mode 100644 index 0000000..34c3ee9 --- /dev/null +++ b/lib/skillPermissions.ts @@ -0,0 +1,51 @@ +/** + * Skill 的写权限 —— 前端**唯一实现**,与后端 `_can_edit_skill` 一一对应: + * + * 作者本人 / 超级管理员 / **本租户**的租户管理员 + * + * 2026-08-04 发现的问题:Skill 列表的「编辑」「删除」按钮是无条件渲染的。部门管理员 + * 登进去,别人的 skill 上也是两个亮着的按钮 —— 点下去后端 403 拦住(数据是安全的), + * 但界面在骗人:它先告诉你"你可以改",再在你动手之后说"不行"。用户会以为是系统坏了, + * 而不是自己没权限。 + * + * 角色布尔值由调用方传入(与 `canEditApp` 同形态),不在这里 import clientPermissions: + * 那个模块是给组件用的,跨 lib 引它会让这个纯函数变得没法单测。 + * + * ⚠️ 这里只管**显示**。真正的边界在后端 —— 藏起按钮不构成任何安全保证。 + * 改这里时不要以为自己在做安全,安全那半边在服务端。 + */ + +interface SkillLike { + user_id?: number | null; + owner_tenant_id?: number | null; +} + +interface UserLike { + id?: number | null; + tenant_id?: number | null; +} + +export function canEditSkill( + skill: SkillLike | null | undefined, + user: UserLike | null | undefined, + isSuperAdmin: boolean, + isTenantAdmin: boolean +): boolean { + if (isSuperAdmin) return true; + if (!skill || !user) return false; + + // 作者本人。两边都要有 id 才谈得上"同一个人":任一为空时按无权限处理, + // 否则 undefined === undefined 会让人看起来像每个无主 skill 的作者 + if (skill.user_id != null && user.id != null && skill.user_id === user.id) { + return true; + } + + // 租户管理员只管自己租户的(与后端 is_reviewer 同口径)。 + // owner_tenant_id 为空的 skill(无主租户)只有超管能碰。 + return ( + isTenantAdmin && + user.tenant_id != null && + skill.owner_tenant_id != null && + user.tenant_id === skill.owner_tenant_id + ); +} diff --git a/messages/en/common.json b/messages/en/common.json index 446ce30..d7bfded 100644 --- a/messages/en/common.json +++ b/messages/en/common.json @@ -121,5 +121,6 @@ "platformName": "AI Platform", "platformLogoText": "R", "platformTitle": "RAgent AI Platform", - "platformDescription": "RAgent AI Platform" + "platformDescription": "RAgent AI Platform", + "view": "View" } diff --git a/messages/en/skills.json b/messages/en/skills.json index b64755b..2ff44c0 100644 --- a/messages/en/skills.json +++ b/messages/en/skills.json @@ -263,5 +263,7 @@ "envValueErrorTooLong": "A value cannot exceed 8192 characters", "envValueErrorNul": "A value cannot contain NUL characters", "envErrorTooManyKeys": "At most 50 environment variables", - "envErrorTooLarge": "The variables cannot exceed 64 KB in total" + "envErrorTooLarge": "The variables cannot exceed 64 KB in total", + "bindSuccessWithTools": "Skill bound; required tools added automatically: {tools}", + "author": "Creator" } diff --git a/messages/zh-CN/common.json b/messages/zh-CN/common.json index 8cb9472..5f2615d 100644 --- a/messages/zh-CN/common.json +++ b/messages/zh-CN/common.json @@ -121,5 +121,6 @@ "platformName": "智能体中台", "platformLogoText": "睿", "platformTitle": "RAgent 智能体中台", - "platformDescription": "RAgent 智能体中台" + "platformDescription": "RAgent 智能体中台", + "view": "查看" } diff --git a/messages/zh-CN/skills.json b/messages/zh-CN/skills.json index 72654fa..1c306aa 100644 --- a/messages/zh-CN/skills.json +++ b/messages/zh-CN/skills.json @@ -263,5 +263,7 @@ "envValueErrorTooLong": "值不能超过 8192 个字符", "envValueErrorNul": "值不能包含 NUL 字符", "envErrorTooManyKeys": "环境变量不能超过 50 项", - "envErrorTooLarge": "环境变量总体积不能超过 64 KB" + "envErrorTooLarge": "环境变量总体积不能超过 64 KB", + "bindSuccessWithTools": "已绑定 Skill,并自动补齐依赖工具:{tools}", + "author": "创建者" } diff --git a/test/appPermissions.test.ts b/test/appPermissions.test.ts new file mode 100644 index 0000000..8c5ce87 --- /dev/null +++ b/test/appPermissions.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { canEditApp } from "../lib/appPermissions.ts"; + +test("owner 能改自己的应用", () => { + assert.equal(canEditApp({ user_id: 7 }, { id: 7 }, false), true); +}); + +test("超管能改别人的应用", () => { + assert.equal(canEditApp({ user_id: 7 }, { id: 999 }, true), true); +}); + +test("其他人不能改", () => { + assert.equal(canEditApp({ user_id: 7 }, { id: 999 }, false), false); +}); + +test("id 缺失时按无权限处理", () => { + // 关键一条:两边都空时不能相等。`undefined === undefined` 为 true, + // 未登录用户会看起来像每个无主应用的 owner —— 按钮全亮,点下去才吃 403。 + assert.equal(canEditApp({ user_id: null }, { id: null }, false), false); + assert.equal(canEditApp({}, {}, false), false); + assert.equal(canEditApp(null, null, false), false); + assert.equal(canEditApp(undefined, undefined, false), false); +}); + +test("超管即便没有可比的 id 也能改", () => { + // 超管判定不依赖 owner 是谁;无主应用(user_id 为空)也得能管 + assert.equal(canEditApp({ user_id: null }, { id: null }, true), true); +}); diff --git a/test/skillPermissions.test.ts b/test/skillPermissions.test.ts new file mode 100644 index 0000000..c343f48 --- /dev/null +++ b/test/skillPermissions.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { canEditSkill } from "../lib/skillPermissions.ts"; + +// 角色布尔由调用方传入,所以这里直接给 (user, isSuper, isTenantAdmin) 三件套 +const superAdmin = { id: 9, tenant_id: 1 }; +const tenantAdminT1 = { id: 6, tenant_id: 1 }; +const tenantAdminT2 = { id: 7, tenant_id: 2 }; +const deptAdmin = { id: 1, tenant_id: 1 }; +const plain = { id: 5, tenant_id: 1 }; + +const adminsSkill = { user_id: 2, owner_tenant_id: 1 }; + +test("作者本人可以改", () => { + assert.equal(canEditSkill({ user_id: 5, owner_tenant_id: 1 }, plain, false, false), true); +}); + +test("超级管理员可以改别人的", () => { + assert.equal(canEditSkill(adminsSkill, superAdmin, true, false), true); +}); + +test("同租户的租户管理员可以改", () => { + assert.equal(canEditSkill(adminsSkill, tenantAdminT1, false, true), true); +}); + +test("别的租户的租户管理员不能改", () => { + assert.equal(canEditSkill(adminsSkill, tenantAdminT2, false, true), false); +}); + +test("部门管理员不能改别人的", () => { + // 这就是 2026-08-04 报上来的那一幕:square 是部门管理员, + // 界面上却在别人的 skill 上给了他「编辑」「删除」两个亮按钮 + assert.equal(canEditSkill(adminsSkill, deptAdmin, false, false), false); +}); + +test("普通用户不能改别人的", () => { + assert.equal(canEditSkill(adminsSkill, plain, false, false), false); +}); + +test("无主租户的 skill 只有超管能碰", () => { + const orphan = { user_id: null, owner_tenant_id: null }; + assert.equal(canEditSkill(orphan, superAdmin, true, false), true); + assert.equal(canEditSkill(orphan, tenantAdminT1, false, true), false); + assert.equal(canEditSkill(orphan, plain, false, false), false); +}); + +test("id 缺失时按无权限处理", () => { + // `undefined === undefined` 为 true,不挡住的话会让人看起来像每个无主 skill 的作者 + assert.equal(canEditSkill({ user_id: null }, { id: null }, false, false), false); + assert.equal(canEditSkill({}, {}, false, false), false); + assert.equal(canEditSkill(null, plain, false, false), false); + assert.equal(canEditSkill(adminsSkill, null, false, false), false); +}); diff --git a/types/skill.ts b/types/skill.ts index b54eff1..d552a58 100644 --- a/types/skill.ts +++ b/types/skill.ts @@ -28,6 +28,8 @@ export interface Skill { is_active: boolean; /** 作者用户 ID,编辑权判定用 */ user_id?: number | null; + /** 创建者显示名(nickname 优先退 username);作者账号已注销时为空 */ + author?: string | null; created_at: string; updated_at: string; }