mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 22:17:48 +02:00
* feat(permissions): add core permission module and shared UI primitives
Foundation for permission-aware UI: pure rules that mirror the Go backend
permission gates, lightweight per-resource hooks, and two reusable display
components used across agent/skill/runtime detail pages.
- packages/core/permissions: types, rules, hooks (Decision-shaped — carries
reason + message so UI can render disabled state, tooltip, and banner
copy from one source)
- packages/core/agents/visibility-label: VISIBILITY_LABEL/DESCRIPTION/TOOLTIP
constants ("Personal" / "Workspace") to replace scattered hard-coded copy
- packages/views/agents/visibility-badge: read-only visibility chip used on
hover cards, list rows, and inspector when not editable
- packages/ui/components/common/capability-banner: "View only — only X and
admins can edit Y" banner shown on agent / skill detail when current user
lacks edit permission
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(views): permission-aware UI across agent/comment/runtime/skill surfaces
Apply the new permission rules to every surface where the UI was either
lying about who can do what or letting users hit 403s by clicking buttons
the backend would reject.
Agent detail
- Hide archive/restore actions for non-owner non-admin
- Replace inline editors (avatar, name, description, runtime/model/visibility/
concurrency picker, skill-attach) with read-only display when canEdit is
false — value is information, the editor is the action
- Show CapabilityBanner under the header explaining who can edit
Visibility surfaces
- visibility-picker / create-agent-dialog: replace "only you can assign"
(false) with "Only you and workspace admins can assign" via shared
VISIBILITY_DESCRIPTION constants
- agent-columns: truthful tooltip + "You" badge on agents the current user
owns
Comments
- Restore admin override on comment edit/delete (backend already permits
it via comment.go:507-512; the frontend was incorrectly hiding the menu).
canModerate is computed once in issue-detail and threaded down.
Other
- Members tab: disable "demote" options for the last owner with tooltip
- Assignee picker: tooltip on disabled personal agents the user can't assign
- Runtime delete: tooltip and dialog explain the gate; owner column gains
a name label next to the avatar in All scope
- Skill detail: page-level CapabilityBanner alongside the existing lock chip
- Issue delete (single + batch): note that any workspace member can delete
issues — by-design semantics, made transparent
Backend is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agents): hide personal agents from list and @mention for non-owners
Until now an agent's "Personal" visibility only narrowed the assign-to-issue
gate — every workspace member still saw every personal agent in the list
and the @mention dropdown. Members would see, click, and fail.
This filters those surfaces with the canonical canAssignAgentToIssue rule:
regular members only see workspace-visibility agents and the personal
agents they own; workspace owners and admins continue to see everything
(admin override path is intact).
- agents-page: visibleInView layer between active/archived and Mine/All
scope so segment counts also reflect the filter
- mention-suggestion: filter agentItems before they enter the recency-
ranked list; expand the test mock to cover the auth + visibility paths
and add two assertions (member hides others' personal agents; admin
still sees them)
Backend keeps returning every agent — admin tools and direct API access
are unaffected. This is a UI-only filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
330 lines
10 KiB
TypeScript
330 lines
10 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { Agent, Comment, Member, RuntimeDevice, Skill } from "../types";
|
|
import {
|
|
canAssignAgentToIssue,
|
|
canChangeMemberRole,
|
|
canDeleteComment,
|
|
canDeleteRuntime,
|
|
canDeleteSkill,
|
|
canDeleteWorkspace,
|
|
canEditAgent,
|
|
canEditComment,
|
|
canEditSkill,
|
|
canManageMembers,
|
|
canUpdateWorkspaceSettings,
|
|
} from "./rules";
|
|
|
|
const ALICE = "user-alice";
|
|
const BOB = "user-bob";
|
|
|
|
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
|
return {
|
|
id: "agt_1",
|
|
workspace_id: "ws_1",
|
|
runtime_id: "rt_1",
|
|
name: "agent",
|
|
description: "",
|
|
instructions: "",
|
|
avatar_url: null,
|
|
runtime_mode: "local",
|
|
runtime_config: {},
|
|
custom_env: {},
|
|
custom_args: [],
|
|
custom_env_redacted: false,
|
|
visibility: "workspace",
|
|
status: "idle",
|
|
max_concurrent_tasks: 1,
|
|
model: "default",
|
|
owner_id: ALICE,
|
|
skills: [],
|
|
created_at: "2026-04-01T00:00:00Z",
|
|
updated_at: "2026-04-01T00:00:00Z",
|
|
archived_at: null,
|
|
archived_by: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeSkill(createdBy: string | null): Skill {
|
|
return {
|
|
id: "skl_1",
|
|
workspace_id: "ws_1",
|
|
name: "skill",
|
|
description: "",
|
|
content: "",
|
|
config: {},
|
|
files: [],
|
|
created_by: createdBy,
|
|
created_at: "2026-04-01T00:00:00Z",
|
|
updated_at: "2026-04-01T00:00:00Z",
|
|
};
|
|
}
|
|
|
|
function makeComment(overrides: Partial<Comment> = {}): Comment {
|
|
return {
|
|
id: "cmt_1",
|
|
issue_id: "iss_1",
|
|
author_type: "member",
|
|
author_id: ALICE,
|
|
content: "hi",
|
|
type: "comment",
|
|
parent_id: null,
|
|
reactions: [],
|
|
attachments: [],
|
|
created_at: "2026-04-01T00:00:00Z",
|
|
updated_at: "2026-04-01T00:00:00Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeRuntime(ownerId: string | null): RuntimeDevice {
|
|
return {
|
|
id: "rt_1",
|
|
workspace_id: "ws_1",
|
|
daemon_id: null,
|
|
name: "runtime",
|
|
runtime_mode: "local",
|
|
provider: "anthropic",
|
|
launch_header: "",
|
|
status: "online",
|
|
device_info: "",
|
|
metadata: {},
|
|
owner_id: ownerId,
|
|
last_seen_at: null,
|
|
created_at: "2026-04-01T00:00:00Z",
|
|
updated_at: "2026-04-01T00:00:00Z",
|
|
};
|
|
}
|
|
|
|
describe("canEditAgent", () => {
|
|
const agent = makeAgent({ owner_id: ALICE });
|
|
|
|
it("allows the owner", () => {
|
|
expect(canEditAgent(agent, { userId: ALICE, role: "member" }).allowed).toBe(
|
|
true,
|
|
);
|
|
});
|
|
it("allows workspace owner", () => {
|
|
expect(canEditAgent(agent, { userId: BOB, role: "owner" }).allowed).toBe(
|
|
true,
|
|
);
|
|
});
|
|
it("allows workspace admin", () => {
|
|
expect(canEditAgent(agent, { userId: BOB, role: "admin" }).allowed).toBe(
|
|
true,
|
|
);
|
|
});
|
|
it("denies non-owner member", () => {
|
|
const d = canEditAgent(agent, { userId: BOB, role: "member" });
|
|
expect(d.allowed).toBe(false);
|
|
expect(d.reason).toBe("not_resource_owner");
|
|
});
|
|
it("denies when userId is null", () => {
|
|
const d = canEditAgent(agent, { userId: null, role: null });
|
|
expect(d.allowed).toBe(false);
|
|
expect(d.reason).toBe("not_authenticated");
|
|
});
|
|
it("denies when agent owner_id is null and user is plain member", () => {
|
|
const orphan = makeAgent({ owner_id: null });
|
|
expect(
|
|
canEditAgent(orphan, { userId: ALICE, role: "member" }).allowed,
|
|
).toBe(false);
|
|
});
|
|
it("admin can still edit an orphan (owner_id null) agent", () => {
|
|
const orphan = makeAgent({ owner_id: null });
|
|
expect(canEditAgent(orphan, { userId: BOB, role: "admin" }).allowed).toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("canAssignAgentToIssue", () => {
|
|
it("allows any member to assign workspace-visibility agents", () => {
|
|
const a = makeAgent({ visibility: "workspace", owner_id: ALICE });
|
|
expect(
|
|
canAssignAgentToIssue(a, { userId: BOB, role: "member" }).allowed,
|
|
).toBe(true);
|
|
});
|
|
it("denies non-members from assigning workspace agents", () => {
|
|
const a = makeAgent({ visibility: "workspace", owner_id: ALICE });
|
|
const d = canAssignAgentToIssue(a, { userId: BOB, role: null });
|
|
expect(d.allowed).toBe(false);
|
|
expect(d.reason).toBe("not_member");
|
|
});
|
|
it("allows the owner to assign their private agent", () => {
|
|
const a = makeAgent({ visibility: "private", owner_id: ALICE });
|
|
expect(
|
|
canAssignAgentToIssue(a, { userId: ALICE, role: "member" }).allowed,
|
|
).toBe(true);
|
|
});
|
|
it("allows workspace admin to assign someone else's private agent", () => {
|
|
const a = makeAgent({ visibility: "private", owner_id: ALICE });
|
|
expect(
|
|
canAssignAgentToIssue(a, { userId: BOB, role: "admin" }).allowed,
|
|
).toBe(true);
|
|
});
|
|
it("denies a plain member from assigning someone else's private agent", () => {
|
|
const a = makeAgent({ visibility: "private", owner_id: ALICE });
|
|
const d = canAssignAgentToIssue(a, { userId: BOB, role: "member" });
|
|
expect(d.allowed).toBe(false);
|
|
expect(d.reason).toBe("private_visibility");
|
|
});
|
|
it("denies logged-out users", () => {
|
|
const a = makeAgent({ visibility: "workspace" });
|
|
const d = canAssignAgentToIssue(a, { userId: null, role: null });
|
|
expect(d.allowed).toBe(false);
|
|
expect(d.reason).toBe("not_authenticated");
|
|
});
|
|
});
|
|
|
|
describe("canEditSkill / canDeleteSkill", () => {
|
|
const skill = makeSkill(ALICE);
|
|
it("allows admins", () => {
|
|
expect(canEditSkill(skill, { userId: BOB, role: "admin" }).allowed).toBe(
|
|
true,
|
|
);
|
|
});
|
|
it("allows the creator", () => {
|
|
expect(canEditSkill(skill, { userId: ALICE, role: "member" }).allowed)
|
|
.toBe(true);
|
|
});
|
|
it("denies non-creator member", () => {
|
|
expect(canEditSkill(skill, { userId: BOB, role: "member" }).allowed)
|
|
.toBe(false);
|
|
});
|
|
it("denies when created_by is null and user is plain member", () => {
|
|
expect(
|
|
canEditSkill(makeSkill(null), { userId: ALICE, role: "member" }).allowed,
|
|
).toBe(false);
|
|
});
|
|
it("canDeleteSkill mirrors canEditSkill", () => {
|
|
expect(canDeleteSkill(skill, { userId: ALICE, role: "member" }).allowed)
|
|
.toBe(true);
|
|
expect(canDeleteSkill(skill, { userId: BOB, role: "member" }).allowed)
|
|
.toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("canEditComment / canDeleteComment", () => {
|
|
it("allows the author to edit their own comment", () => {
|
|
const c = makeComment({ author_id: ALICE });
|
|
expect(canEditComment(c, { userId: ALICE, role: "member" }).allowed).toBe(
|
|
true,
|
|
);
|
|
});
|
|
it("allows workspace admin to edit someone else's comment", () => {
|
|
const c = makeComment({ author_id: ALICE });
|
|
expect(canEditComment(c, { userId: BOB, role: "admin" }).allowed).toBe(
|
|
true,
|
|
);
|
|
});
|
|
it("denies non-author non-admin", () => {
|
|
const c = makeComment({ author_id: ALICE });
|
|
expect(canEditComment(c, { userId: BOB, role: "member" }).allowed).toBe(
|
|
false,
|
|
);
|
|
});
|
|
it("denies edit on agent-authored comments", () => {
|
|
const c = makeComment({ author_type: "agent", author_id: "agt_1" });
|
|
const d = canEditComment(c, { userId: BOB, role: "owner" });
|
|
expect(d.allowed).toBe(false);
|
|
expect(d.reason).toBe("not_resource_owner");
|
|
});
|
|
it("admin CAN delete an agent-authored comment", () => {
|
|
// delete is broader than edit — admins moderate any comment regardless of
|
|
// author type. Mirrors backend `comment.go:507-512`.
|
|
const c = makeComment({ author_type: "agent", author_id: "agt_1" });
|
|
expect(canDeleteComment(c, { userId: BOB, role: "admin" }).allowed).toBe(
|
|
true,
|
|
);
|
|
});
|
|
it("denies plain member from deleting agent-authored comment", () => {
|
|
const c = makeComment({ author_type: "agent", author_id: "agt_1" });
|
|
expect(
|
|
canDeleteComment(c, { userId: BOB, role: "member" }).allowed,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("canDeleteRuntime", () => {
|
|
it("allows the owner", () => {
|
|
const r = makeRuntime(ALICE);
|
|
expect(canDeleteRuntime(r, { userId: ALICE, role: "member" }).allowed)
|
|
.toBe(true);
|
|
});
|
|
it("allows workspace admin", () => {
|
|
const r = makeRuntime(ALICE);
|
|
expect(canDeleteRuntime(r, { userId: BOB, role: "admin" }).allowed).toBe(
|
|
true,
|
|
);
|
|
});
|
|
it("denies non-owner non-admin", () => {
|
|
const r = makeRuntime(ALICE);
|
|
expect(canDeleteRuntime(r, { userId: BOB, role: "member" }).allowed)
|
|
.toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("workspace-level rules", () => {
|
|
it("only owner can delete workspace", () => {
|
|
expect(canDeleteWorkspace({ userId: ALICE, role: "owner" }).allowed).toBe(
|
|
true,
|
|
);
|
|
expect(canDeleteWorkspace({ userId: ALICE, role: "admin" }).allowed).toBe(
|
|
false,
|
|
);
|
|
expect(canDeleteWorkspace({ userId: ALICE, role: "member" }).allowed)
|
|
.toBe(false);
|
|
});
|
|
it("owner+admin can update settings, member cannot", () => {
|
|
expect(
|
|
canUpdateWorkspaceSettings({ userId: ALICE, role: "owner" }).allowed,
|
|
).toBe(true);
|
|
expect(
|
|
canUpdateWorkspaceSettings({ userId: ALICE, role: "admin" }).allowed,
|
|
).toBe(true);
|
|
expect(
|
|
canUpdateWorkspaceSettings({ userId: ALICE, role: "member" }).allowed,
|
|
).toBe(false);
|
|
});
|
|
it("manage members same gate as settings", () => {
|
|
expect(canManageMembers({ userId: ALICE, role: "admin" }).allowed).toBe(
|
|
true,
|
|
);
|
|
expect(canManageMembers({ userId: ALICE, role: "member" }).allowed).toBe(
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("canChangeMemberRole", () => {
|
|
const ctxOwner = { userId: ALICE, role: "owner" as const };
|
|
const ctxAdmin = { userId: ALICE, role: "admin" as const };
|
|
const ctxMember = { userId: ALICE, role: "member" as const };
|
|
|
|
const targetOwner: Pick<Member, "role"> = { role: "owner" };
|
|
const targetAdmin: Pick<Member, "role"> = { role: "admin" };
|
|
const targetMember: Pick<Member, "role"> = { role: "member" };
|
|
|
|
it("non-managers cannot change roles", () => {
|
|
expect(canChangeMemberRole(targetMember, 2, ctxMember).allowed).toBe(false);
|
|
});
|
|
it("admin cannot change owner's role", () => {
|
|
const d = canChangeMemberRole(targetOwner, 2, ctxAdmin);
|
|
expect(d.allowed).toBe(false);
|
|
expect(d.reason).toBe("not_owner_role");
|
|
});
|
|
it("admin can change admin/member roles", () => {
|
|
expect(canChangeMemberRole(targetAdmin, 1, ctxAdmin).allowed).toBe(true);
|
|
expect(canChangeMemberRole(targetMember, 1, ctxAdmin).allowed).toBe(true);
|
|
});
|
|
it("owner cannot demote the last owner", () => {
|
|
const d = canChangeMemberRole(targetOwner, 1, ctxOwner);
|
|
expect(d.allowed).toBe(false);
|
|
expect(d.reason).toBe("last_owner");
|
|
});
|
|
it("owner can change owner role when 2+ owners exist", () => {
|
|
expect(canChangeMemberRole(targetOwner, 2, ctxOwner).allowed).toBe(true);
|
|
});
|
|
});
|