refactor(skills): extract edit-permission hook and origin helper

- 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) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-04-24 13:33:31 +08:00
parent d77af9e672
commit 45dd4f9fca
3 changed files with 143 additions and 0 deletions

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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<string, unknown>)
| 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;
}