Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions app/apps/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
// 状态徽标并入基本信息首行;动作/提示另起一行,且只在真有内容时渲染
Expand Down Expand Up @@ -476,7 +479,7 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{t("boundTools", { count: appTools.length })}</CardTitle>
{checkSuperAdmin(user) && (
{canEditThisApp && (
<Button size="sm" onClick={() => setBindDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
{t("bindTools")}
Expand Down Expand Up @@ -541,7 +544,7 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
{checkSuperAdmin(user) && (
{canEditThisApp && (
<>
<Button
variant="ghost"
Expand Down Expand Up @@ -573,14 +576,15 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string
</Card>

{/* Skills 绑定区 */}
<AppSkillsSection appId={appId} />
<AppSkillsSection appId={appId} ownerUserId={appInfo.user_id} />

{/* Skill 生效诊断(requires 门控去静默) */}
<AppSkillDiagnostics appId={appId} onBindTools={() => setBindDialogOpen(true)} />

{/* Agent.md 编辑区块 */}
<AgentMdEditor
appId={appId}
ownerUserId={appInfo.user_id}
platform={appInfo.platform}
promptId={appInfo.prompt_id}
onChanged={loadAppInfo}
Expand Down
13 changes: 11 additions & 2 deletions app/apps/components/AgentMdEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ import {
import { useAgentMd, type AgentMdValidationError } from "@/hooks/useAgentMd";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { checkSuperAdmin } from "@/lib/clientPermissions";
import { canEditApp } from "@/lib/appPermissions";

interface AgentMdEditorProps {
appId: number;
/** app 的创建者。后端的 agent-md 写入一直是 owner 或超管,UI 此前只放超管 */
ownerUserId?: number | null;
platform: string;
/**
* 应用绑定的 legacy prompt。没有它就没有"升级"可言——文案要说「创建」,
Expand All @@ -44,11 +47,17 @@ interface AgentMdEditorProps {
* 走不到这一支;留着是为了存量应用。
* - 已有 agent_md:全文编辑 + 保存(422 按行号展示)+ 导出视图 + 回退到提示词(二次确认)
*/
export default function AgentMdEditor({ appId, platform, promptId, onChanged }: AgentMdEditorProps) {
export default function AgentMdEditor({
appId,
ownerUserId,
platform,
promptId,
onChanged,
}: AgentMdEditorProps) {
const t = useTranslations("skills");
const tc = useTranslations("common");
const { user } = useCurrentUser();
const canManage = checkSuperAdmin(user);
const canManage = canEditApp({ user_id: ownerUserId }, user, checkSuperAdmin(user));

const { agentMd, loading, save, generate, fetchExport } = useAgentMd(appId);

Expand Down
14 changes: 12 additions & 2 deletions app/apps/components/AppSkillsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,29 @@ import { useAppSkills, normalizeAppSkill } from "@/hooks/useAppSkills";
import { useSkills } from "@/hooks/useSkills";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { checkSuperAdmin, checkTenantAdmin } from "@/lib/clientPermissions";
import { canEditApp } from "@/lib/appPermissions";
import type { AppSkill } from "@/types/skill";

/**
* 应用详情页的 Skills 绑定区(照工具绑定区模式):
* 已绑列表(发布状态 / 解绑)+ 添加绑定(可见 skills 搜索多选)。
* 列表顺序沿用后端返回的绑定顺序,前端不再排序。
*/
export default function AppSkillsSection({ appId }: { appId: number }) {
export default function AppSkillsSection({
appId,
ownerUserId,
}: {
appId: number;
/** app 的创建者。owner 也能绑/解绑(后端同规矩),不传则只有管理员能管 */
ownerUserId?: number | null;
}) {
const router = useRouter();
const t = useTranslations("skills");
const tc = useTranslations("common");
const { user } = useCurrentUser();
const canManage = checkSuperAdmin(user) || checkTenantAdmin(user);
// owner 或超管(与后端 require_app_owner_or_super 同一条规矩);租户管理员沿用既有权限
const canManage =
canEditApp({ user_id: ownerUserId }, user, checkSuperAdmin(user)) || checkTenantAdmin(user);

const { appSkills, loading, bindSkill, unbindSkill } = useAppSkills(appId);
const [bindDialogOpen, setBindDialogOpen] = useState(false);
Expand Down
5 changes: 3 additions & 2 deletions app/apps/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { triggerLabel } from "@/lib/appTrigger";
import { canEditApp } from "@/lib/appPermissions";
import AppAvatarPicker from "./components/AppAvatarPicker";
import AppAvatar from "./components/AppAvatar";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
Expand Down Expand Up @@ -878,7 +879,7 @@ export default function AppsPage() {
</Tooltip>
</TooltipProvider>
)}
{isSuperAdmin && (
{canEditApp(app, user, isSuperAdmin) && (
<>
<TooltipProvider>
<Tooltip>
Expand Down Expand Up @@ -1100,7 +1101,7 @@ export default function AppsPage() {
</Tooltip>
</TooltipProvider>
)}
{isSuperAdmin && (
{canEditApp(app, user, isSuperAdmin) && (
<>
<TooltipProvider>
<Tooltip>
Expand Down
15 changes: 13 additions & 2 deletions app/skills/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import SkillUserEnvPanel from "../components/SkillUserEnvPanel";
import { useSkill, type SkillPayload } from "@/hooks/useSkills";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { checkSuperAdmin, checkTenantAdmin } from "@/lib/clientPermissions";
import { canEditSkill } from "@/lib/skillPermissions";
import { parseSaveWarnings, takeSaveWarnings } from "@/lib/skillRequires";

export default function SkillDetailPage({ params }: { params: Promise<{ id: string }> }) {
Expand Down Expand Up @@ -41,8 +42,18 @@ export default function SkillDetailPage({ params }: { params: Promise<{ id: stri

// P5:具备审核权者直接发布(自审即过),普通用户走提交审核
const canReview = checkSuperAdmin(user) || checkTenantAdmin(user);
// P8:资产/exec 配置编辑权 = 作者本人 + 管理员,与后端 _can_edit_skill 同口径
const canEditAssets = canReview || (skill?.user_id != null && skill.user_id === user?.id);
// 资产/exec 配置编辑权 = 作者本人 / 超管 / **本租户**租户管理员,与后端
// _can_edit_skill 同口径(共用 lib/skillPermissions,别在这里另写一份)。
//
// 原先写的是 `canReview || 作者本人`,而 canReview 里的 checkTenantAdmin 不带租户范围
// ——别的租户的租户管理员在这一页能改,后端 is_reviewer 却要求同租户。松的一侧是界面,
// 所以表现为"能编辑能保存,保存时 403"。
const canEditAssets = canEditSkill(
skill,
user,
checkSuperAdmin(user),
checkTenantAdmin(user)
);

const handleSaveDraft = async (payload: SkillPayload) => {
setSaving(true);
Expand Down
58 changes: 42 additions & 16 deletions app/skills/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
resolveReviewStatus,
reviewStatusBadge,
} from "@/lib/reviewStatus";
import { canEditSkill } from "@/lib/skillPermissions";
import { checkSuperAdmin, checkTenantAdmin } from "@/lib/clientPermissions";
import type { Skill } from "@/types/skill";

const visibilityColors: Record<string, string> = {
Expand All @@ -46,6 +48,8 @@ export default function SkillsPage() {
const t = useTranslations("skills");
const tc = useTranslations("common");
const { user, loading: userLoading } = useCurrentUser();
const isSuperAdmin = checkSuperAdmin(user);
const isTenantAdmin = checkTenantAdmin(user);

const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search, 300);
Expand Down Expand Up @@ -112,8 +116,9 @@ export default function SkillsPage() {
<TableHead>{t("name")}</TableHead>
<TableHead>{t("displayName")}</TableHead>
<TableHead className="max-w-md">{t("description")}</TableHead>
<TableHead>{t("visibility")}</TableHead>
<TableHead>{tc("status")}</TableHead>
<TableHead className="whitespace-nowrap">{t("author")}</TableHead>
<TableHead className="whitespace-nowrap">{t("visibility")}</TableHead>
<TableHead className="whitespace-nowrap">{tc("status")}</TableHead>
<TableHead className="text-right">{tc("actions")}</TableHead>
</TableRow>
</TableHeader>
Expand All @@ -138,6 +143,11 @@ export default function SkillsPage() {
{skill.description}
</span>
</TableCell>
<TableCell className="text-sm">
{/* 作者账号注销后 author 为空:显示占位符而不是空白单元格,
空白会让人以为是渲染坏了 */}
{skill.author || <span className="text-muted-foreground">—</span>}
</TableCell>
<TableCell>
<Badge
className={`text-xs ${visibilityColors[skill.visibility] || visibilityColors.private}`}
Expand All @@ -160,20 +170,36 @@ export default function SkillsPage() {
</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/skills/${skill.id}`)}
>
{tc("edit")}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(skill)}
>
{tc("delete")}
</Button>
{/* 没有写权限的人不该看到「编辑」「删除」——原先两个按钮无条件渲染,
点下去才被后端 403 拦住。界面先说"你可以改"、动手后再说"不行",
用户会以为是系统坏了而不是自己没权限。
但详情页他是能看的,所以按钮换成「查看」而不是整个消失。 */}
{canEditSkill(skill, user, isSuperAdmin, isTenantAdmin) ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/skills/${skill.id}`)}
>
{tc("edit")}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(skill)}
>
{tc("delete")}
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/skills/${skill.id}`)}
>
{tc("view")}
</Button>
)}
</div>
</TableCell>
</TableRow>
Expand Down
22 changes: 20 additions & 2 deletions hooks/useAppSkills.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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, {
Expand All @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions lib/appPermissions.ts
Original file line number Diff line number Diff line change
@@ -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;
}
51 changes: 51 additions & 0 deletions lib/skillPermissions.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
Loading
Loading