mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-17 07:09:53 +02:00
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:
66
packages/views/skills/hooks/use-can-edit-skill.test.ts
Normal file
66
packages/views/skills/hooks/use-can-edit-skill.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
42
packages/views/skills/hooks/use-can-edit-skill.ts
Normal file
42
packages/views/skills/hooks/use-can-edit-skill.ts
Normal 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;
|
||||
}
|
||||
35
packages/views/skills/lib/origin.ts
Normal file
35
packages/views/skills/lib/origin.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user