From 45dd4f9fca58aaaed1b36569f06343efea1ceb1f Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:33:31 +0800 Subject: [PATCH] refactor(skills): extract edit-permission hook and origin helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - use-can-edit-skill: mirrors the server's rule (admin/owner ∨ creator) so the UI can hide/disable actions instead of waiting for a 403. Takes wsId explicitly per the repo rule for workspace-aware hooks. - lib/origin: discriminated view over Skill.config.origin (manual / runtime_local / clawhub / skills_sh) so consumers don't spread JSONB parsing across the UI tree. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../skills/hooks/use-can-edit-skill.test.ts | 66 +++++++++++++++++++ .../views/skills/hooks/use-can-edit-skill.ts | 42 ++++++++++++ packages/views/skills/lib/origin.ts | 35 ++++++++++ 3 files changed, 143 insertions(+) create mode 100644 packages/views/skills/hooks/use-can-edit-skill.test.ts create mode 100644 packages/views/skills/hooks/use-can-edit-skill.ts create mode 100644 packages/views/skills/lib/origin.ts diff --git a/packages/views/skills/hooks/use-can-edit-skill.test.ts b/packages/views/skills/hooks/use-can-edit-skill.test.ts new file mode 100644 index 0000000000..adbe79df17 --- /dev/null +++ b/packages/views/skills/hooks/use-can-edit-skill.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import type { Skill } from "@multica/core/types"; +import { canEditSkill } from "./use-can-edit-skill"; + +function makeSkill(createdBy: string | null): Skill { + return { + id: "skl_x", + workspace_id: "ws_1", + name: "x", + description: "", + content: "", + config: {}, + files: [], + created_by: createdBy, + created_at: "2026-04-01T00:00:00Z", + updated_at: "2026-04-01T00:00:00Z", + }; +} + +describe("canEditSkill", () => { + const skill = makeSkill("user-alice"); + + it("allows workspace owners to edit any skill", () => { + expect( + canEditSkill(skill, { userId: "user-bob", role: "owner" }), + ).toBe(true); + }); + + it("allows workspace admins to edit any skill", () => { + expect( + canEditSkill(skill, { userId: "user-bob", role: "admin" }), + ).toBe(true); + }); + + it("allows the creator to edit their own skill", () => { + expect( + canEditSkill(skill, { userId: "user-alice", role: "member" }), + ).toBe(true); + }); + + it("denies non-creator members", () => { + expect( + canEditSkill(skill, { userId: "user-bob", role: "member" }), + ).toBe(false); + }); + + it("denies unknown-role users even if they match created_by", () => { + // role=null models a member list that hasn't loaded yet or a user who + // isn't a member at all; we still honor created_by identity. + expect( + canEditSkill(skill, { userId: "user-alice", role: null }), + ).toBe(true); + }); + + it("denies when created_by is null (legacy / system-created)", () => { + expect( + canEditSkill(makeSkill(null), { userId: "user-alice", role: "member" }), + ).toBe(false); + }); + + it("denies when userId is null (logged-out edge case)", () => { + expect( + canEditSkill(skill, { userId: null, role: "member" }), + ).toBe(false); + }); +}); diff --git a/packages/views/skills/hooks/use-can-edit-skill.ts b/packages/views/skills/hooks/use-can-edit-skill.ts new file mode 100644 index 0000000000..c07f445652 --- /dev/null +++ b/packages/views/skills/hooks/use-can-edit-skill.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import type { MemberRole, Skill } from "@multica/core/types"; +import { useAuthStore } from "@multica/core/auth"; +import { memberListOptions } from "@multica/core/workspace/queries"; + +/** + * Whether the current user may edit/delete the given skill. + * + * Rule: workspace admins & owners can edit any skill; everyone else can only + * edit skills they created. Server enforces this independently; the hook + * mirrors it so the UI can hide/disable actions instead of waiting for a 403. + * + * `wsId` is explicit (not read from `WorkspaceIdProvider`) so this hook stays + * usable in components that render before workspace context is wired, and so + * the scope of the permission check is always obvious to the caller. Matches + * the repo rule for workspace-aware hooks. + */ +export function useCanEditSkill( + skill: Skill | null | undefined, + wsId: string, +): boolean { + const userId = useAuthStore((s) => s.user?.id ?? null); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + + if (!skill) return false; + const myRole = members.find((m) => m.user_id === userId)?.role ?? null; + return canEditSkill(skill, { userId, role: myRole }); +} + +/** + * Non-hook variant for places that already have the role + userId at hand + * (e.g. list rows that compute role once for the whole page). + */ +export function canEditSkill( + skill: Skill, + opts: { userId: string | null; role: MemberRole | null }, +): boolean { + if (opts.role === "admin" || opts.role === "owner") return true; + return skill.created_by === opts.userId; +} diff --git a/packages/views/skills/lib/origin.ts b/packages/views/skills/lib/origin.ts new file mode 100644 index 0000000000..f0273ae88a --- /dev/null +++ b/packages/views/skills/lib/origin.ts @@ -0,0 +1,35 @@ +import type { Skill } from "@multica/core/types"; + +/** + * Discriminated view over `Skill.config.origin` — the JSONB blob the backend + * writes when a skill was imported from outside (local runtime, ClawHub, + * Skills.sh). Manual creates have no origin, so we synthesize `{ type: + * "manual" }` for them to keep the consumer code uniform. + * + * NOTE: the backend currently only writes `runtime_local` origins. URL + * imports leave `config.origin` empty, so `clawhub`/`skills_sh` variants are + * declared here for forward compatibility but should never be rendered in + * the UI until the server fills them in. + */ +export type OriginInfo = { + type: "runtime_local" | "clawhub" | "skills_sh" | "manual"; + provider?: string; + runtime_id?: string; + source_path?: string; + source_url?: string; +}; + +export function readOrigin(skill: Skill): OriginInfo { + const raw = (skill.config?.origin ?? null) as + | (OriginInfo & Record) + | null; + if (raw?.type === "runtime_local") return raw; + if (raw?.type === "clawhub") return raw; + if (raw?.type === "skills_sh") return raw; + return { type: "manual" }; +} + +/** SKILL.md is always present plus any additional attached files. */ +export function totalFileCount(skill: Skill): number { + return (skill.files?.length ?? 0) + 1; +}