mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 18:09:14 +02:00
Compare commits
3 Commits
agent/lamb
...
feat/permi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6468b80e57 | ||
|
|
db28370c3d | ||
|
|
de0f9e4bad |
@@ -5,3 +5,4 @@ export * from "./use-agent-presence";
|
||||
export * from "./use-agent-activity";
|
||||
export * from "./use-workspace-presence-prefetch";
|
||||
export * from "./constants";
|
||||
export * from "./visibility-label";
|
||||
|
||||
31
packages/core/agents/visibility-label.ts
Normal file
31
packages/core/agents/visibility-label.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { AgentVisibility } from "../types";
|
||||
|
||||
/**
|
||||
* Display labels for agent visibility. The DB stores `private` as the value
|
||||
* but the UI surface name is "Personal" — better matches what the field
|
||||
* actually means now that workspace admins can also assign private agents.
|
||||
*/
|
||||
export const VISIBILITY_LABEL: Record<AgentVisibility, string> = {
|
||||
workspace: "Workspace",
|
||||
private: "Personal",
|
||||
};
|
||||
|
||||
/**
|
||||
* Honest descriptions for assignability. The previous "Only you can assign"
|
||||
* text was a lie — workspace owners and admins can assign private agents too
|
||||
* (server `issue.go:1471-1490`).
|
||||
*/
|
||||
export const VISIBILITY_DESCRIPTION: Record<AgentVisibility, string> = {
|
||||
workspace: "All members can assign",
|
||||
private: "Only you and workspace admins can assign",
|
||||
};
|
||||
|
||||
/** Tooltip suitable for read-only badges on hover/list rows. */
|
||||
export const VISIBILITY_TOOLTIP: Record<AgentVisibility, string> = {
|
||||
workspace: "Workspace — all members can assign",
|
||||
private: "Personal — only you and workspace admins can assign",
|
||||
};
|
||||
|
||||
export function visibilityLabel(v: AgentVisibility): string {
|
||||
return VISIBILITY_LABEL[v];
|
||||
}
|
||||
@@ -46,6 +46,8 @@
|
||||
"./agents/queries": "./agents/queries.ts",
|
||||
"./agents/derive-presence": "./agents/derive-presence.ts",
|
||||
"./agents/use-agent-presence": "./agents/use-agent-presence.ts",
|
||||
"./agents/visibility-label": "./agents/visibility-label.ts",
|
||||
"./permissions": "./permissions/index.ts",
|
||||
"./projects": "./projects/index.ts",
|
||||
"./projects/queries": "./projects/queries.ts",
|
||||
"./projects/mutations": "./projects/mutations.ts",
|
||||
|
||||
20
packages/core/permissions/index.ts
Normal file
20
packages/core/permissions/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Public API for the permissions module.
|
||||
*
|
||||
* Exports only what the views currently consume. The full pure-rule set lives
|
||||
* in `./rules` and is available to tests and future surfaces directly. Adding
|
||||
* a new rule to the public API should follow the same minimum-surface pattern
|
||||
* — only export when there's a caller.
|
||||
*/
|
||||
export type {
|
||||
Decision,
|
||||
DecisionReason,
|
||||
PermissionContext,
|
||||
} from "./types";
|
||||
|
||||
export { canAssignAgentToIssue, canEditAgent } from "./rules";
|
||||
|
||||
export {
|
||||
useAgentPermissions,
|
||||
useSkillPermissions,
|
||||
} from "./use-resource-permissions";
|
||||
329
packages/core/permissions/rules.test.ts
Normal file
329
packages/core/permissions/rules.test.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
210
packages/core/permissions/rules.ts
Normal file
210
packages/core/permissions/rules.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type {
|
||||
Agent,
|
||||
Comment,
|
||||
Member,
|
||||
MemberRole,
|
||||
RuntimeDevice,
|
||||
Skill,
|
||||
} from "../types";
|
||||
import { ALLOW, deny, type Decision, type PermissionContext } from "./types";
|
||||
|
||||
/**
|
||||
* Pure permission rules — single source of truth that mirrors the Go backend
|
||||
* gates in `server/internal/handler/`. Hooks in `use-resource-permissions.ts`
|
||||
* are thin wrappers that pull `PermissionContext` from auth + member queries
|
||||
* and forward to these.
|
||||
*
|
||||
* Returning a `Decision` (not a boolean) lets every surface — disabled state,
|
||||
* tooltip, banner copy — read the same `reason` and stay consistent without
|
||||
* sprinkling copy through the view layer.
|
||||
*/
|
||||
|
||||
const isAdminLike = (role: MemberRole | null) =>
|
||||
role === "owner" || role === "admin";
|
||||
|
||||
// ---- Agents ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update / archive / restore agent fields. The backend gates archive and
|
||||
* restore identically to edit (`server/internal/handler/agent.go:519-535`),
|
||||
* so callers can use `canEditAgent` for all three.
|
||||
*/
|
||||
export function canEditAgent(agent: Agent, ctx: PermissionContext): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to edit this agent.");
|
||||
}
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
if (agent.owner_id !== null && agent.owner_id === ctx.userId) return ALLOW;
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Only the agent owner and workspace admins can edit this agent.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign an agent to an issue. Workspace-visibility agents are assignable by
|
||||
* any workspace member; private agents are restricted to their owner plus
|
||||
* workspace admins/owners. Mirrors `issue.go:1471-1490`.
|
||||
*/
|
||||
export function canAssignAgentToIssue(
|
||||
agent: Agent,
|
||||
ctx: PermissionContext,
|
||||
): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to assign agents.");
|
||||
}
|
||||
if (agent.visibility === "workspace") {
|
||||
if (ctx.role === null) {
|
||||
return deny("not_member", "Join this workspace to assign agents.");
|
||||
}
|
||||
return ALLOW;
|
||||
}
|
||||
// visibility === "private"
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
if (agent.owner_id !== null && agent.owner_id === ctx.userId) return ALLOW;
|
||||
return deny(
|
||||
"private_visibility",
|
||||
"Personal agent — only the owner and workspace admins can assign work.",
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Skills ----------------------------------------------------------------
|
||||
|
||||
export function canEditSkill(skill: Skill, ctx: PermissionContext): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to edit this skill.");
|
||||
}
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
if (skill.created_by !== null && skill.created_by === ctx.userId) {
|
||||
return ALLOW;
|
||||
}
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Only the creator and workspace admins can edit this skill.",
|
||||
);
|
||||
}
|
||||
|
||||
export function canDeleteSkill(skill: Skill, ctx: PermissionContext): Decision {
|
||||
return canEditSkill(skill, ctx);
|
||||
}
|
||||
|
||||
// ---- Comments --------------------------------------------------------------
|
||||
|
||||
export function canEditComment(
|
||||
comment: Comment,
|
||||
ctx: PermissionContext,
|
||||
): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to edit comments.");
|
||||
}
|
||||
// Only member-authored comments can be edited; agent-authored comments are
|
||||
// immutable from any human's perspective.
|
||||
if (comment.author_type !== "member") {
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Agent-authored comments cannot be edited.",
|
||||
);
|
||||
}
|
||||
if (comment.author_id === ctx.userId) return ALLOW;
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Only the author and workspace admins can edit this comment.",
|
||||
);
|
||||
}
|
||||
|
||||
export function canDeleteComment(
|
||||
comment: Comment,
|
||||
ctx: PermissionContext,
|
||||
): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to delete comments.");
|
||||
}
|
||||
if (comment.author_type === "member" && comment.author_id === ctx.userId) {
|
||||
return ALLOW;
|
||||
}
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Only the author and workspace admins can delete this comment.",
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Runtimes --------------------------------------------------------------
|
||||
|
||||
export function canDeleteRuntime(
|
||||
runtime: RuntimeDevice,
|
||||
ctx: PermissionContext,
|
||||
): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to delete runtimes.");
|
||||
}
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
if (runtime.owner_id !== null && runtime.owner_id === ctx.userId) {
|
||||
return ALLOW;
|
||||
}
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Only the runtime owner and workspace admins can delete this runtime.",
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Workspace -------------------------------------------------------------
|
||||
|
||||
export function canUpdateWorkspaceSettings(ctx: PermissionContext): Decision {
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
return deny(
|
||||
"not_admin_role",
|
||||
"Only workspace owners and admins can update workspace settings.",
|
||||
);
|
||||
}
|
||||
|
||||
export function canDeleteWorkspace(ctx: PermissionContext): Decision {
|
||||
if (ctx.role === "owner") return ALLOW;
|
||||
return deny(
|
||||
"not_owner_role",
|
||||
"Only the workspace owner can delete this workspace.",
|
||||
);
|
||||
}
|
||||
|
||||
export function canManageMembers(ctx: PermissionContext): Decision {
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
return deny(
|
||||
"not_admin_role",
|
||||
"Only workspace owners and admins can manage members.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the role-change matrix from `workspace.go:458-530`:
|
||||
* - admins cannot touch the owner role (neither demote owners nor promote)
|
||||
* - the last owner cannot be demoted
|
||||
* - non-managers cannot change roles at all
|
||||
*
|
||||
* `ownerCount` is the number of workspace members currently with role=owner.
|
||||
* Caller derives it locally from the cached member list.
|
||||
*/
|
||||
export function canChangeMemberRole(
|
||||
target: Pick<Member, "role">,
|
||||
ownerCount: number,
|
||||
ctx: PermissionContext,
|
||||
): Decision {
|
||||
const manage = canManageMembers(ctx);
|
||||
if (!manage.allowed) return manage;
|
||||
|
||||
if (target.role === "owner") {
|
||||
if (ctx.role !== "owner") {
|
||||
return deny(
|
||||
"not_owner_role",
|
||||
"Only the workspace owner can change another owner's role.",
|
||||
);
|
||||
}
|
||||
if (ownerCount <= 1) {
|
||||
return deny(
|
||||
"last_owner",
|
||||
"Promote another member to owner first — a workspace must keep at least one owner.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return ALLOW;
|
||||
}
|
||||
52
packages/core/permissions/types.ts
Normal file
52
packages/core/permissions/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { MemberRole } from "../types";
|
||||
|
||||
/**
|
||||
* Inputs to every permission rule. Stays role-typed so we don't have to thread
|
||||
* `MemberWithUser` (with PII) into pure logic — only what we actually need.
|
||||
*
|
||||
* `userId === null` models the logged-out edge case; `role === null` models the
|
||||
* "not a workspace member" / "member list still loading" case. Both must
|
||||
* gracefully deny without throwing.
|
||||
*/
|
||||
export interface PermissionContext {
|
||||
userId: string | null;
|
||||
role: MemberRole | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable enum of *why* a permission was denied (or allowed). Lets UIs pick
|
||||
* different copy / disabled states / banner variants without parsing the
|
||||
* `message` string. Tests assert on `reason`.
|
||||
*/
|
||||
export type DecisionReason =
|
||||
| "allowed"
|
||||
| "not_authenticated"
|
||||
| "not_member"
|
||||
| "not_owner_role"
|
||||
| "not_admin_role"
|
||||
| "not_resource_owner"
|
||||
| "last_owner"
|
||||
| "private_visibility"
|
||||
| "unknown";
|
||||
|
||||
export interface Decision {
|
||||
allowed: boolean;
|
||||
reason: DecisionReason;
|
||||
/**
|
||||
* Human-readable copy for tooltips / banners. Centralised here so view code
|
||||
* doesn't drift. UI may still wrap it for emphasis but should not invent
|
||||
* its own copy.
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Builder helpers — keeps rules.ts tight. */
|
||||
export const ALLOW: Decision = {
|
||||
allowed: true,
|
||||
reason: "allowed",
|
||||
message: "",
|
||||
};
|
||||
|
||||
export function deny(reason: DecisionReason, message: string): Decision {
|
||||
return { allowed: false, reason, message };
|
||||
}
|
||||
32
packages/core/permissions/use-current-member.ts
Normal file
32
packages/core/permissions/use-current-member.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "../auth";
|
||||
import type { MemberRole, MemberWithUser } from "../types";
|
||||
import { memberListOptions } from "../workspace/queries";
|
||||
|
||||
/**
|
||||
* Resolves the current user's membership in the given workspace. Single source
|
||||
* of truth for "what role am I" — replaces ad-hoc `members.find(...)` lookups
|
||||
* scattered across the views.
|
||||
*
|
||||
* `wsId` is explicit (not via `useWorkspaceId()` Context) so this hook stays
|
||||
* usable in components that may render before workspace context is wired,
|
||||
* matching the repo rule for workspace-aware hooks.
|
||||
*/
|
||||
export function useCurrentMember(wsId: string): {
|
||||
userId: string | null;
|
||||
role: MemberRole | null;
|
||||
member: MemberWithUser | null;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const userId = useAuthStore((s) => s.user?.id ?? null);
|
||||
const { data: members, isLoading } = useQuery(memberListOptions(wsId));
|
||||
const member = members?.find((m) => m.user_id === userId) ?? null;
|
||||
return {
|
||||
userId,
|
||||
role: member?.role ?? null,
|
||||
member,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
65
packages/core/permissions/use-resource-permissions.ts
Normal file
65
packages/core/permissions/use-resource-permissions.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import type { Agent, Skill } from "../types";
|
||||
import { useCurrentMember } from "./use-current-member";
|
||||
import {
|
||||
canAssignAgentToIssue,
|
||||
canDeleteSkill,
|
||||
canEditAgent,
|
||||
canEditSkill,
|
||||
} from "./rules";
|
||||
import { deny, type Decision } from "./types";
|
||||
|
||||
const PENDING: Decision = deny("unknown", "");
|
||||
|
||||
/**
|
||||
* Per-resource hook that returns a `Decision` for every relevant capability.
|
||||
* Each hook calls `useCurrentMember()` once and threads the context into the
|
||||
* pure rules in `rules.ts`.
|
||||
*
|
||||
* `wsId` is explicit (not read from `WorkspaceIdProvider`) so the hook stays
|
||||
* usable outside a workspace context — matches the repo rule for
|
||||
* workspace-aware hooks.
|
||||
*
|
||||
* Resource = `null` collapses every Decision to a denied "unknown" — keeps
|
||||
* callers branch-free during loading.
|
||||
*
|
||||
* `canArchive` / `canRestore` / `canManage` are deliberately not exposed:
|
||||
* the backend gates them identically to `canEdit`, so callers can use
|
||||
* `canEdit` everywhere and read better at the call site.
|
||||
*/
|
||||
export function useAgentPermissions(
|
||||
agent: Agent | null,
|
||||
wsId: string,
|
||||
): {
|
||||
canEdit: Decision;
|
||||
canAssign: Decision;
|
||||
} {
|
||||
const { userId, role } = useCurrentMember(wsId);
|
||||
const ctx = { userId, role };
|
||||
if (agent === null) {
|
||||
return { canEdit: PENDING, canAssign: PENDING };
|
||||
}
|
||||
return {
|
||||
canEdit: canEditAgent(agent, ctx),
|
||||
canAssign: canAssignAgentToIssue(agent, ctx),
|
||||
};
|
||||
}
|
||||
|
||||
export function useSkillPermissions(
|
||||
skill: Skill | null,
|
||||
wsId: string,
|
||||
): {
|
||||
canEdit: Decision;
|
||||
canDelete: Decision;
|
||||
} {
|
||||
const { userId, role } = useCurrentMember(wsId);
|
||||
const ctx = { userId, role };
|
||||
if (skill === null) {
|
||||
return { canEdit: PENDING, canDelete: PENDING };
|
||||
}
|
||||
return {
|
||||
canEdit: canEditSkill(skill, ctx),
|
||||
canDelete: canDeleteSkill(skill, ctx),
|
||||
};
|
||||
}
|
||||
90
packages/ui/components/common/capability-banner.tsx
Normal file
90
packages/ui/components/common/capability-banner.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Lock } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
type Resource = "agent" | "skill" | "comment" | "runtime" | "workspace";
|
||||
|
||||
type Reason =
|
||||
| "allowed"
|
||||
| "not_authenticated"
|
||||
| "not_member"
|
||||
| "not_owner_role"
|
||||
| "not_admin_role"
|
||||
| "not_resource_owner"
|
||||
| "last_owner"
|
||||
| "private_visibility"
|
||||
| "unknown";
|
||||
|
||||
const RESOURCE_NOUN: Record<Resource, string> = {
|
||||
agent: "agent",
|
||||
skill: "skill",
|
||||
comment: "comment",
|
||||
runtime: "runtime",
|
||||
workspace: "workspace",
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only banner for resource detail pages — appears when the current user
|
||||
* cannot edit the resource. Single component owns all the copy variants so
|
||||
* the wording stays consistent across agent, skill, runtime detail pages.
|
||||
*
|
||||
* Returns `null` when the user *can* edit (reason === "allowed") so callers
|
||||
* can mount it unconditionally.
|
||||
*/
|
||||
export function CapabilityBanner({
|
||||
reason,
|
||||
resource,
|
||||
ownerName,
|
||||
className,
|
||||
}: {
|
||||
reason: Reason;
|
||||
resource: Resource;
|
||||
/** Display name of the resource owner / creator. Optional — copy degrades gracefully. */
|
||||
ownerName?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
if (reason === "allowed" || reason === "unknown") return null;
|
||||
|
||||
const noun = RESOURCE_NOUN[resource];
|
||||
const message = getCopy(reason, noun, ownerName);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border border-dashed bg-muted/30 px-3 py-2 text-xs text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Lock className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCopy(reason: Reason, noun: string, ownerName?: string): string {
|
||||
switch (reason) {
|
||||
case "not_authenticated":
|
||||
return `Sign in to edit this ${noun}.`;
|
||||
case "not_member":
|
||||
return `Join this workspace to edit this ${noun}.`;
|
||||
case "not_owner_role":
|
||||
return `View only — only the workspace owner can manage this ${noun}.`;
|
||||
case "not_admin_role":
|
||||
return `View only — only workspace owners and admins can manage this ${noun}.`;
|
||||
case "not_resource_owner":
|
||||
if (ownerName) {
|
||||
return `View only — only ${ownerName} and workspace admins can edit this ${noun}.`;
|
||||
}
|
||||
return `View only — only the ${noun} owner and workspace admins can edit this ${noun}.`;
|
||||
case "last_owner":
|
||||
return `A workspace must keep at least one owner — promote another member first.`;
|
||||
case "private_visibility":
|
||||
if (ownerName) {
|
||||
return `Personal ${noun} — only ${ownerName} and workspace admins can use this.`;
|
||||
}
|
||||
return `Personal ${noun} — only the owner and workspace admins can use this.`;
|
||||
case "allowed":
|
||||
case "unknown":
|
||||
return ""; // unreachable; component returned null above
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type AgentActivity,
|
||||
type AgentPresenceDetail,
|
||||
summarizeActivityWindow,
|
||||
VISIBILITY_TOOLTIP,
|
||||
} from "@multica/core/agents";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -30,6 +31,8 @@ export interface AgentRow {
|
||||
// Inline owner avatar — non-null when the page wants to attribute the
|
||||
// agent to a teammate (typically All scope on someone else's agent).
|
||||
ownerIdToShow: string | null;
|
||||
// True when the current user owns this agent (drives the "You" badge).
|
||||
isOwnedByMe: boolean;
|
||||
// True when the current user can archive / cancel-tasks on this agent.
|
||||
canManage: boolean;
|
||||
}
|
||||
@@ -150,7 +153,7 @@ export function createAgentColumns({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentNameCell({ row }: { row: AgentRow }) {
|
||||
const { agent, ownerIdToShow } = row;
|
||||
const { agent, ownerIdToShow, isOwnedByMe } = row;
|
||||
const isArchived = !!agent.archived_at;
|
||||
const isPrivate = agent.visibility === "private";
|
||||
|
||||
@@ -180,10 +183,15 @@ function AgentNameCell({ row }: { row: AgentRow }) {
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
Private — only the owner can assign work
|
||||
{VISIBILITY_TOOLTIP.private}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isOwnedByMe && !ownerIdToShow && (
|
||||
<span className="shrink-0 rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
{ownerIdToShow && (
|
||||
<ActorAvatar
|
||||
actorType="member"
|
||||
|
||||
@@ -55,6 +55,15 @@ interface InspectorProps {
|
||||
runtimes: AgentRuntime[];
|
||||
members: MemberWithUser[];
|
||||
currentUserId: string | null;
|
||||
/**
|
||||
* Computed by the parent via `useAgentPermissions(agent).canEdit.allowed`.
|
||||
* When false the inspector renders all editable surfaces as static
|
||||
* read-only displays — pickers become text/badges, name/description lose
|
||||
* their pencil affordance, the avatar is no longer clickable, and the
|
||||
* "Attach skill" trigger is hidden. Mirrors the backend gate at
|
||||
* `server/internal/handler/agent.go:519-535`.
|
||||
*/
|
||||
canEdit: boolean;
|
||||
onUpdate: (id: string, data: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -77,6 +86,7 @@ export function AgentDetailInspector({
|
||||
runtimes,
|
||||
members,
|
||||
currentUserId,
|
||||
canEdit,
|
||||
onUpdate,
|
||||
}: InspectorProps) {
|
||||
const update = (data: Record<string, unknown>) => onUpdate(agent.id, data);
|
||||
@@ -86,16 +96,18 @@ export function AgentDetailInspector({
|
||||
<aside className="flex h-full min-h-0 w-full flex-col overflow-y-auto rounded-lg border bg-background">
|
||||
{/* Identity */}
|
||||
<div className="flex flex-col gap-3 border-b px-5 pb-5 pt-5">
|
||||
<AvatarEditor agent={agent} onUpdate={update} />
|
||||
<NameAndDescription agent={agent} onUpdate={update} />
|
||||
<AvatarEditor agent={agent} canEdit={canEdit} onUpdate={update} />
|
||||
<NameAndDescription
|
||||
agent={agent}
|
||||
canEdit={canEdit}
|
||||
onUpdate={update}
|
||||
/>
|
||||
<PresenceBadge presence={presence} />
|
||||
</div>
|
||||
|
||||
{/* Properties — editable. Row hover is OFF here on purpose: each chip
|
||||
(RuntimePicker, ModelPicker, …) carries its own border + hover-bg
|
||||
treatment that already telegraphs "this is a button". A second
|
||||
row-wide hover layer on top would just smudge the chip boundary
|
||||
and make it harder, not easier, to see what's clickable. */}
|
||||
{/* Properties — editable when canEdit. When the current user lacks
|
||||
permission, each picker self-renders a static read-only display so
|
||||
the value is visible but not interactive. */}
|
||||
<Section label="Properties">
|
||||
<PropRow label="Runtime" interactive={false}>
|
||||
<RuntimePicker
|
||||
@@ -103,6 +115,7 @@ export function AgentDetailInspector({
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={currentUserId}
|
||||
canEdit={canEdit}
|
||||
onChange={(id) => update({ runtime_id: id })}
|
||||
/>
|
||||
</PropRow>
|
||||
@@ -111,18 +124,21 @@ export function AgentDetailInspector({
|
||||
runtimeId={agent.runtime_id}
|
||||
runtimeOnline={!!isOnline}
|
||||
value={agent.model ?? ""}
|
||||
canEdit={canEdit}
|
||||
onChange={(m) => update({ model: m })}
|
||||
/>
|
||||
</PropRow>
|
||||
<PropRow label="Visibility" interactive={false}>
|
||||
<VisibilityPicker
|
||||
value={agent.visibility}
|
||||
canEdit={canEdit}
|
||||
onChange={(v) => update({ visibility: v })}
|
||||
/>
|
||||
</PropRow>
|
||||
<PropRow label="Concurrency" interactive={false}>
|
||||
<ConcurrencyPicker
|
||||
value={agent.max_concurrent_tasks}
|
||||
canEdit={canEdit}
|
||||
onChange={(n) => update({ max_concurrent_tasks: n })}
|
||||
/>
|
||||
</PropRow>
|
||||
@@ -173,7 +189,7 @@ export function AgentDetailInspector({
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
<SkillAttach agent={agent} />
|
||||
<SkillAttach agent={agent} canEdit={canEdit} />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -207,14 +223,29 @@ function Section({
|
||||
|
||||
function AvatarEditor({
|
||||
agent,
|
||||
canEdit,
|
||||
onUpdate,
|
||||
}: {
|
||||
agent: Agent;
|
||||
canEdit: boolean;
|
||||
onUpdate: (data: Record<string, unknown>) => Promise<void>;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { upload, uploading } = useFileUpload(api);
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted">
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={agent.id}
|
||||
size={56}
|
||||
className="rounded-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -267,11 +298,32 @@ function AvatarEditor({
|
||||
|
||||
function NameAndDescription({
|
||||
agent,
|
||||
canEdit,
|
||||
onUpdate,
|
||||
}: {
|
||||
agent: Agent;
|
||||
canEdit: boolean;
|
||||
onUpdate: (data: Record<string, unknown>) => Promise<void>;
|
||||
}) {
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-semibold leading-tight">
|
||||
{agent.name}
|
||||
</span>
|
||||
{agent.description ? (
|
||||
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||
{agent.description}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs italic leading-relaxed text-muted-foreground/50">
|
||||
No description
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<InlineEditPopover
|
||||
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
workspaceKeys,
|
||||
} from "@multica/core/workspace/queries";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { useAgentPermissions } from "@multica/core/permissions";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { CapabilityBanner } from "@multica/ui/components/common/capability-banner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -74,6 +76,12 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
const presence: AgentPresenceDetail | null =
|
||||
agent ? presenceMap.get(agent.id) ?? null : null;
|
||||
|
||||
// Permission hook MUST be called unconditionally — its `agent | null`
|
||||
// signature handles the not-found / loading case internally so the early
|
||||
// returns below don't violate the rules of hooks. Backend gates archive
|
||||
// and restore identically to edit, so a single `canEdit` covers them all.
|
||||
const { canEdit } = useAgentPermissions(agent, wsId);
|
||||
|
||||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
|
||||
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
|
||||
@@ -163,23 +171,36 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
agent={agent}
|
||||
presence={presence}
|
||||
backHref={paths.agents()}
|
||||
canArchive={canEdit.allowed}
|
||||
onArchive={() => setConfirmArchive(true)}
|
||||
/>
|
||||
|
||||
{!canEdit.allowed && (
|
||||
<div className="px-6 pt-3">
|
||||
<CapabilityBanner
|
||||
reason={canEdit.reason}
|
||||
resource="agent"
|
||||
ownerName={owner?.name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isArchived && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/50 px-6 py-2 text-xs text-muted-foreground">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1">
|
||||
This agent is archived. It cannot be assigned or mentioned.
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => handleRestore(agent.id)}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
{canEdit.allowed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => handleRestore(agent.id)}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -192,6 +213,7 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={currentUser?.id ?? null}
|
||||
canEdit={canEdit.allowed}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
|
||||
@@ -254,11 +276,13 @@ function DetailHeader({
|
||||
agent,
|
||||
presence,
|
||||
backHref,
|
||||
canArchive,
|
||||
onArchive,
|
||||
}: {
|
||||
agent: Agent;
|
||||
presence: AgentPresenceDetail | null;
|
||||
backHref: string;
|
||||
canArchive: boolean;
|
||||
onArchive: () => void;
|
||||
}) {
|
||||
const isArchived = !!agent.archived_at;
|
||||
@@ -290,7 +314,7 @@ function DetailHeader({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isArchived && (
|
||||
{!isArchived && canArchive && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="ghost" size="icon-sm" />}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { AppLink } from "../../navigation";
|
||||
import { HealthIcon } from "../../runtimes/components/shared";
|
||||
import { availabilityConfig } from "../presence";
|
||||
import { VisibilityBadge } from "./visibility-badge";
|
||||
|
||||
interface AgentProfileCardProps {
|
||||
agentId: string;
|
||||
@@ -81,6 +82,7 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="truncate text-sm font-semibold">{agent.name}</p>
|
||||
{!isArchived && <VisibilityBadge value={agent.visibility} compact />}
|
||||
{isArchived && (
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
Archived
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { api } from "@multica/core/api";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { canAssignAgentToIssue } from "@multica/core/permissions";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import {
|
||||
agentListOptions,
|
||||
@@ -143,27 +144,42 @@ export function AgentsPage() {
|
||||
[agents, view],
|
||||
);
|
||||
|
||||
// Layer 1b — ownership scope. Counts shown on the segment are
|
||||
// computed against the inView set so the numbers always reflect
|
||||
// Layer 1b — visibility. Personal (visibility=private) agents owned by
|
||||
// someone else are hidden from regular members; workspace owners/admins
|
||||
// still see everything. Mirrors the assign-to-issue gate so the list
|
||||
// only ever shows agents the user could actually act on. Backend keeps
|
||||
// returning all agents, so admin tools (and the API itself) are
|
||||
// unaffected — this is a UI-only filter.
|
||||
const visibleInView = useMemo(() => {
|
||||
return inView.filter((a) =>
|
||||
canAssignAgentToIssue(a, {
|
||||
userId: currentUser?.id ?? null,
|
||||
role: myRole,
|
||||
}).allowed,
|
||||
);
|
||||
}, [inView, currentUser?.id, myRole]);
|
||||
|
||||
// Layer 1c — ownership scope. Counts shown on the segment are
|
||||
// computed against the visibleInView set so the numbers always reflect
|
||||
// "what would I see if I clicked this".
|
||||
const scopeCounts = useMemo(() => {
|
||||
let mine = 0;
|
||||
if (currentUser) {
|
||||
for (const a of inView) {
|
||||
for (const a of visibleInView) {
|
||||
if (a.owner_id === currentUser.id) mine += 1;
|
||||
}
|
||||
}
|
||||
return { all: inView.length, mine };
|
||||
}, [inView, currentUser]);
|
||||
return { all: visibleInView.length, mine };
|
||||
}, [visibleInView, currentUser]);
|
||||
|
||||
const inScope = useMemo(() => {
|
||||
// Archived view ignores Mine / All — its toolbar has no scope
|
||||
// segment, so silently filtering by `scope` would hide other
|
||||
// people's archived agents without any UI to explain why.
|
||||
if (view === "archived") return inView;
|
||||
if (scope === "all" || !currentUser) return inView;
|
||||
return inView.filter((a) => a.owner_id === currentUser.id);
|
||||
}, [inView, scope, currentUser, view]);
|
||||
if (view === "archived") return visibleInView;
|
||||
if (scope === "all" || !currentUser) return visibleInView;
|
||||
return visibleInView.filter((a) => a.owner_id === currentUser.id);
|
||||
}, [visibleInView, scope, currentUser, view]);
|
||||
|
||||
// Final cut — availability chip + search.
|
||||
const filteredAgents = useMemo(() => {
|
||||
@@ -311,6 +327,7 @@ export function AgentsPage() {
|
||||
activity: activityMap.get(agent.id) ?? null,
|
||||
runCount: runCountsById.get(agent.id) ?? 0,
|
||||
ownerIdToShow,
|
||||
isOwnedByMe: isOwner,
|
||||
canManage,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -29,7 +29,11 @@ import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { AGENT_DESCRIPTION_MAX_LENGTH } from "@multica/core/agents";
|
||||
import {
|
||||
AGENT_DESCRIPTION_MAX_LENGTH,
|
||||
VISIBILITY_DESCRIPTION,
|
||||
VISIBILITY_LABEL,
|
||||
} from "@multica/core/agents";
|
||||
import { CharCounter } from "./char-counter";
|
||||
|
||||
type RuntimeFilter = "mine" | "all";
|
||||
@@ -202,8 +206,10 @@ export function CreateAgentDialog({
|
||||
>
|
||||
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Workspace</div>
|
||||
<div className="text-xs text-muted-foreground">All members can assign</div>
|
||||
<div className="font-medium">{VISIBILITY_LABEL.workspace}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{VISIBILITY_DESCRIPTION.workspace}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -217,8 +223,10 @@ export function CreateAgentDialog({
|
||||
>
|
||||
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Private</div>
|
||||
<div className="text-xs text-muted-foreground">Only you can assign</div>
|
||||
<div className="font-medium">{VISIBILITY_LABEL.private}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{VISIBILITY_DESCRIPTION.private}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -11,14 +11,25 @@ const MAX = 50;
|
||||
|
||||
export function ConcurrencyPicker({
|
||||
value,
|
||||
canEdit = true,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
/** When false, render a static read-only display and skip the popover. */
|
||||
canEdit?: boolean;
|
||||
onChange: (next: number) => Promise<void> | void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState(String(value));
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Reset draft from authoritative value whenever the popover (re-)opens or
|
||||
// the prop changes from elsewhere — protects against stale draft state if
|
||||
// the user closes mid-edit and reopens later.
|
||||
|
||||
@@ -26,11 +26,14 @@ export function ModelPicker({
|
||||
runtimeId,
|
||||
runtimeOnline,
|
||||
value,
|
||||
canEdit = true,
|
||||
onChange,
|
||||
}: {
|
||||
runtimeId: string | null;
|
||||
runtimeOnline: boolean;
|
||||
value: string;
|
||||
/** When false, render a static read-only display and skip the popover. */
|
||||
canEdit?: boolean;
|
||||
onChange: (next: string) => Promise<void> | void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -83,6 +86,17 @@ export function ModelPicker({
|
||||
(defaultModel ? `Default — ${defaultModel.label}` : "Default");
|
||||
const triggerTitle = `Model · ${triggerLabel}`;
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<span
|
||||
className="min-w-0 truncate px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
|
||||
title={triggerTitle}
|
||||
>
|
||||
{triggerLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PropertyPicker
|
||||
open={open}
|
||||
|
||||
@@ -24,12 +24,15 @@ export function RuntimePicker({
|
||||
runtimes,
|
||||
members,
|
||||
currentUserId,
|
||||
canEdit = true,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
runtimes: AgentRuntime[];
|
||||
members: MemberWithUser[];
|
||||
currentUserId: string | null;
|
||||
/** When false, render a static read-only display and skip the popover. */
|
||||
canEdit?: boolean;
|
||||
onChange: (runtimeId: string) => Promise<void> | void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -37,6 +40,25 @@ export function RuntimePicker({
|
||||
|
||||
const selected = runtimes.find((r) => r.id === value) ?? null;
|
||||
const Icon = selected?.runtime_mode === "cloud" ? Cloud : Monitor;
|
||||
|
||||
if (!canEdit) {
|
||||
const isOnline = selected?.status === "online";
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
<span className="min-w-0 truncate font-mono">
|
||||
{selected?.name ?? "No runtime"}
|
||||
</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={`ml-auto h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||
isOnline ? "bg-success" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// The chip shows only the runtime name. `runtime.name` already comes back
|
||||
// from the back-end pre-formatted as e.g. "Claude (host.local)", so we
|
||||
// deliberately do NOT append `device_info` to the tooltip — that string
|
||||
|
||||
@@ -17,7 +17,14 @@ import { SkillAddDialog } from "../skill-add-dialog";
|
||||
* Hidden when there's nothing left to attach so we don't dangle a chip
|
||||
* that opens an empty dialog.
|
||||
*/
|
||||
export function SkillAttach({ agent }: { agent: Agent }) {
|
||||
export function SkillAttach({
|
||||
agent,
|
||||
canEdit = true,
|
||||
}: {
|
||||
agent: Agent;
|
||||
/** When false, hide the attach trigger entirely. */
|
||||
canEdit?: boolean;
|
||||
}) {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -27,7 +34,7 @@ export function SkillAttach({ agent }: { agent: Agent }) {
|
||||
(s) => !agentSkillIds.has(s.id),
|
||||
).length;
|
||||
|
||||
if (availableCount === 0) return null;
|
||||
if (!canEdit || availableCount === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,27 +2,38 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Globe, Lock } from "lucide-react";
|
||||
import {
|
||||
VISIBILITY_DESCRIPTION,
|
||||
VISIBILITY_LABEL,
|
||||
VISIBILITY_TOOLTIP,
|
||||
} from "@multica/core/agents";
|
||||
import type { AgentVisibility } from "@multica/core/types";
|
||||
import {
|
||||
PickerItem,
|
||||
PropertyPicker,
|
||||
} from "../../../issues/components/pickers";
|
||||
import { VisibilityBadge } from "../visibility-badge";
|
||||
import { CHIP_CLASS } from "./chip";
|
||||
|
||||
export function VisibilityPicker({
|
||||
value,
|
||||
canEdit = true,
|
||||
onChange,
|
||||
}: {
|
||||
value: AgentVisibility;
|
||||
/** When false, render a read-only `<VisibilityBadge>` and skip the popover. */
|
||||
canEdit?: boolean;
|
||||
onChange: (next: AgentVisibility) => Promise<void> | void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!canEdit) {
|
||||
return <VisibilityBadge value={value} />;
|
||||
}
|
||||
|
||||
const Icon = value === "private" ? Lock : Globe;
|
||||
const label = value === "private" ? "Private" : "Workspace";
|
||||
const tooltip =
|
||||
value === "private"
|
||||
? "Visibility · Private — only you can assign"
|
||||
: "Visibility · Workspace — all members can assign";
|
||||
const label = VISIBILITY_LABEL[value];
|
||||
const tooltip = `Visibility · ${VISIBILITY_TOOLTIP[value]}`;
|
||||
|
||||
const select = async (next: AgentVisibility) => {
|
||||
setOpen(false);
|
||||
@@ -52,9 +63,9 @@ export function VisibilityPicker({
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Workspace</div>
|
||||
<div className="font-medium">{VISIBILITY_LABEL.workspace}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
All members can assign
|
||||
{VISIBILITY_DESCRIPTION.workspace}
|
||||
</div>
|
||||
</div>
|
||||
</PickerItem>
|
||||
@@ -64,9 +75,9 @@ export function VisibilityPicker({
|
||||
>
|
||||
<Lock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Private</div>
|
||||
<div className="font-medium">{VISIBILITY_LABEL.private}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Only you can assign
|
||||
{VISIBILITY_DESCRIPTION.private}
|
||||
</div>
|
||||
</div>
|
||||
</PickerItem>
|
||||
|
||||
49
packages/views/agents/components/visibility-badge.tsx
Normal file
49
packages/views/agents/components/visibility-badge.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Globe, Lock } from "lucide-react";
|
||||
import {
|
||||
VISIBILITY_LABEL,
|
||||
VISIBILITY_TOOLTIP,
|
||||
} from "@multica/core/agents";
|
||||
import type { AgentVisibility } from "@multica/core/types";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
|
||||
/**
|
||||
* Read-only visibility badge — used wherever a user should *see* an agent's
|
||||
* visibility (Personal / Workspace) without being able to change it. Replaces
|
||||
* the interactive `<VisibilityPicker>` for non-managers on the detail page,
|
||||
* and is also the canonical badge for hover cards and list rows.
|
||||
*
|
||||
* `compact` drops the text label and shows just the icon — for tight spaces
|
||||
* like the agent table where the column header already labels the field.
|
||||
*/
|
||||
export function VisibilityBadge({
|
||||
value,
|
||||
compact = false,
|
||||
className = "",
|
||||
}: {
|
||||
value: AgentVisibility;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = value === "private" ? Lock : Globe;
|
||||
const label = VISIBILITY_LABEL[value];
|
||||
const tooltip = VISIBILITY_TOOLTIP[value];
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-xs text-muted-foreground ${className}`}
|
||||
aria-label={tooltip}
|
||||
>
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
{!compact && <span className="truncate">{label}</span>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,13 @@ vi.mock("@multica/core/api", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the auth store: items() reads `useAuthStore.getState()` imperatively
|
||||
// to identify the current user when filtering personal agents.
|
||||
const authState = { user: { id: "u1" } as { id: string } | null };
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
useAuthStore: { getState: () => authState },
|
||||
}));
|
||||
|
||||
import {
|
||||
createMentionSuggestion,
|
||||
MentionList,
|
||||
@@ -29,8 +36,14 @@ import {
|
||||
} from "./mention-suggestion";
|
||||
|
||||
function fakeQc(data: {
|
||||
members?: Array<{ user_id: string; name: string }>;
|
||||
agents?: Array<{ id: string; name: string; archived_at: string | null }>;
|
||||
members?: Array<{ user_id: string; name: string; role?: string }>;
|
||||
agents?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
archived_at: string | null;
|
||||
visibility?: "workspace" | "private";
|
||||
owner_id?: string | null;
|
||||
}>;
|
||||
issues?: Array<{ id: string; identifier: string; title: string; status: string }>;
|
||||
}): QueryClient {
|
||||
const map = new Map<string, unknown>();
|
||||
@@ -57,8 +70,16 @@ describe("createMentionSuggestion", () => {
|
||||
|
||||
it("returns members and agents synchronously without waiting for the server search", () => {
|
||||
const qc = fakeQc({
|
||||
members: [{ user_id: "u1", name: "Alice" }],
|
||||
agents: [{ id: "a1", name: "Aegis", archived_at: null }],
|
||||
members: [{ user_id: "u1", name: "Alice", role: "member" }],
|
||||
agents: [
|
||||
{
|
||||
id: "a1",
|
||||
name: "Aegis",
|
||||
archived_at: null,
|
||||
visibility: "workspace",
|
||||
owner_id: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
// A pending fetch — would block the result if items() awaited it.
|
||||
searchIssuesMock.mockReturnValue(new Promise(() => {}));
|
||||
@@ -119,6 +140,78 @@ describe("createMentionSuggestion", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides personal agents owned by someone else from a regular member", () => {
|
||||
const qc = fakeQc({
|
||||
members: [
|
||||
{ user_id: "u1", name: "Alice", role: "member" },
|
||||
{ user_id: "u2", name: "Bob", role: "member" },
|
||||
],
|
||||
agents: [
|
||||
// Bob's personal agent — Alice (current user) should not see it.
|
||||
{
|
||||
id: "a-personal-bob",
|
||||
name: "Atlas",
|
||||
archived_at: null,
|
||||
visibility: "private",
|
||||
owner_id: "u2",
|
||||
},
|
||||
// Alice's own personal agent — should be visible.
|
||||
{
|
||||
id: "a-personal-alice",
|
||||
name: "Athena",
|
||||
archived_at: null,
|
||||
visibility: "private",
|
||||
owner_id: "u1",
|
||||
},
|
||||
// Workspace agent — visible to everyone.
|
||||
{
|
||||
id: "a-shared",
|
||||
name: "Aether",
|
||||
archived_at: null,
|
||||
visibility: "workspace",
|
||||
owner_id: "u2",
|
||||
},
|
||||
],
|
||||
});
|
||||
searchIssuesMock.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const config = createMentionSuggestion(qc);
|
||||
const result = config.items!({ query: "a", editor: {} as never });
|
||||
const items = result as MentionItem[];
|
||||
|
||||
expect(items.some((i) => i.type === "agent" && i.label === "Athena")).toBe(true);
|
||||
expect(items.some((i) => i.type === "agent" && i.label === "Aether")).toBe(true);
|
||||
expect(items.some((i) => i.type === "agent" && i.label === "Atlas")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows everyone's personal agents to a workspace admin", () => {
|
||||
// Role lives in the member fixture, not in authState — promoting Alice
|
||||
// to admin here is enough to flip the gate. Backend gate allows admins
|
||||
// to assign anyone's personal agent, so the @mention list mirrors that.
|
||||
const qc = fakeQc({
|
||||
members: [
|
||||
{ user_id: "u1", name: "Alice", role: "admin" },
|
||||
{ user_id: "u2", name: "Bob", role: "member" },
|
||||
],
|
||||
agents: [
|
||||
{
|
||||
id: "a-personal-bob",
|
||||
name: "Atlas",
|
||||
archived_at: null,
|
||||
visibility: "private",
|
||||
owner_id: "u2",
|
||||
},
|
||||
],
|
||||
});
|
||||
searchIssuesMock.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const config = createMentionSuggestion(qc);
|
||||
const result = config.items!({ query: "a", editor: {} as never });
|
||||
const items = result as MentionItem[];
|
||||
|
||||
expect(items.some((i) => i.type === "agent" && i.label === "Atlas")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes cached issues in the synchronous response", () => {
|
||||
const qc = fakeQc({
|
||||
issues: [
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
import { getCurrentWsId } from "@multica/core/platform";
|
||||
import { flattenIssueBuckets, issueKeys } from "@multica/core/issues/queries";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { canAssignAgentToIssue } from "@multica/core/permissions";
|
||||
import { api } from "@multica/core/api";
|
||||
import type {
|
||||
Issue,
|
||||
@@ -363,6 +365,15 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
|
||||
const cachedResponse = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const cachedIssues: Issue[] = cachedResponse ? flattenIssueBuckets(cachedResponse) : [];
|
||||
|
||||
// Read current user identity imperatively — this factory runs outside
|
||||
// React render so we can't useAuthStore() as a hook here. The Proxy in
|
||||
// packages/core/auth/index.ts forwards `.getState()` to the registered
|
||||
// store. Used to gate personal agents in the @mention list so members
|
||||
// don't see (or auto-complete) agents they couldn't assign anyway.
|
||||
const userId = useAuthStore.getState().user?.id ?? null;
|
||||
const myRole =
|
||||
members.find((m) => m.user_id === userId)?.role ?? null;
|
||||
|
||||
const q = query.toLowerCase();
|
||||
|
||||
const allItem: MentionItem[] =
|
||||
@@ -379,7 +390,12 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
|
||||
}));
|
||||
|
||||
const agentItems: MentionItem[] = agents
|
||||
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
|
||||
.filter(
|
||||
(a) =>
|
||||
!a.archived_at &&
|
||||
a.name.toLowerCase().includes(q) &&
|
||||
canAssignAgentToIssue(a, { userId, role: myRole }).allowed,
|
||||
)
|
||||
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
|
||||
|
||||
// Members and agents share a single ranked list — recently mentioned
|
||||
|
||||
@@ -127,6 +127,9 @@ export function BatchActionToolbar() {
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
selected issue{count > 1 ? "s" : ""} and all associated data.
|
||||
<span className="mt-2 block text-xs text-muted-foreground/80">
|
||||
Any workspace member can delete issues.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -47,6 +47,14 @@ interface CommentCardProps {
|
||||
entry: TimelineEntry;
|
||||
allReplies: Map<string, TimelineEntry[]>;
|
||||
currentUserId?: string;
|
||||
/**
|
||||
* True when the current user is a workspace owner/admin and can therefore
|
||||
* moderate comments authored by anyone — restoring the admin override that
|
||||
* the backend already grants at `comment.go:507-512`. Computed once in
|
||||
* `issue-detail.tsx` and threaded down so neither this component nor
|
||||
* `CommentRow` has to rerun the rule per row.
|
||||
*/
|
||||
canModerate?: boolean;
|
||||
onReply: (parentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
|
||||
onEdit: (commentId: string, content: string) => Promise<void>;
|
||||
onDelete: (commentId: string) => void;
|
||||
@@ -153,6 +161,7 @@ function CommentRow({
|
||||
issueId,
|
||||
entry,
|
||||
currentUserId,
|
||||
canModerate = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleReaction,
|
||||
@@ -160,6 +169,7 @@ function CommentRow({
|
||||
issueId: string;
|
||||
entry: TimelineEntry;
|
||||
currentUserId?: string;
|
||||
canModerate?: boolean;
|
||||
onEdit: (commentId: string, content: string) => Promise<void>;
|
||||
onDelete: (commentId: string) => void;
|
||||
onToggleReaction: (commentId: string, emoji: string) => void;
|
||||
@@ -175,6 +185,8 @@ function CommentRow({
|
||||
});
|
||||
|
||||
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
||||
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
|
||||
const canDeleteEntry = isOwn || canModerate;
|
||||
const isTemp = entry.id.startsWith("temp-");
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
@@ -252,18 +264,22 @@ function CommentRow({
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy
|
||||
</DropdownMenuItem>
|
||||
{isOwn && (
|
||||
{(canEditEntry || canDeleteEntry) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={startEdit}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
{canEditEntry && (
|
||||
<DropdownMenuItem onClick={startEdit}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canEditEntry && canDeleteEntry && <DropdownMenuSeparator />}
|
||||
{canDeleteEntry && (
|
||||
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
@@ -337,6 +353,7 @@ function CommentCard({
|
||||
entry,
|
||||
allReplies,
|
||||
currentUserId,
|
||||
canModerate = false,
|
||||
onReply,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -358,6 +375,12 @@ function CommentCard({
|
||||
});
|
||||
|
||||
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
||||
// Author-only edit is the same as before; admins additionally get edit
|
||||
// *and* delete on member-authored comments, plus delete on agent-authored
|
||||
// ones. Edit on agent comments is intentionally never offered — agents
|
||||
// own their own outputs.
|
||||
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
|
||||
const canDeleteEntry = isOwn || canModerate;
|
||||
const isTemp = entry.id.startsWith("temp-");
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
@@ -467,18 +490,22 @@ function CommentCard({
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy
|
||||
</DropdownMenuItem>
|
||||
{isOwn && (
|
||||
{(canEditEntry || canDeleteEntry) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={startEdit}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
{canEditEntry && (
|
||||
<DropdownMenuItem onClick={startEdit}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canEditEntry && canDeleteEntry && <DropdownMenuSeparator />}
|
||||
{canDeleteEntry && (
|
||||
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
@@ -554,6 +581,7 @@ function CommentCard({
|
||||
issueId={issueId}
|
||||
entry={reply}
|
||||
currentUserId={currentUserId}
|
||||
canModerate={canModerate}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onToggleReaction={onToggleReaction}
|
||||
|
||||
@@ -159,6 +159,13 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
// Workspace owners and admins moderate any comment authored by anyone
|
||||
// (mirrors backend `comment.go:507-512`). Computed here so per-comment
|
||||
// rendering doesn't have to re-derive it for every row.
|
||||
const currentUserRole =
|
||||
members.find((m) => m.user_id === user?.id)?.role ?? null;
|
||||
const canModerateComments =
|
||||
currentUserRole === "owner" || currentUserRole === "admin";
|
||||
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
|
||||
const { getActorName } = useActorName();
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
@@ -908,6 +915,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
entry={entry}
|
||||
allReplies={repliesByParent}
|
||||
currentUserId={user?.id}
|
||||
canModerate={canModerateComments}
|
||||
onReply={submitReply}
|
||||
onEdit={editComment}
|
||||
onDelete={deleteComment}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Lock, UserMinus } from "lucide-react";
|
||||
import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@multica/core/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { canAssignAgentToIssue } from "@multica/core/permissions";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions, agentListOptions, assigneeFrequencyOptions } from "@multica/core/workspace/queries";
|
||||
@@ -16,11 +17,22 @@ import {
|
||||
PickerEmpty,
|
||||
} from "./property-picker";
|
||||
|
||||
export function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
|
||||
if (agent.visibility !== "private") return true;
|
||||
if (agent.owner_id === userId) return true;
|
||||
if (memberRole === "owner" || memberRole === "admin") return true;
|
||||
return false;
|
||||
/**
|
||||
* Legacy boolean shape kept around for callers (e.g. `use-issue-actions.ts`)
|
||||
* that haven't migrated to the new `canAssignAgentToIssue` Decision API yet.
|
||||
* Internally redirects to the canonical rule so behaviour stays in sync.
|
||||
*/
|
||||
export function canAssignAgent(
|
||||
agent: Agent,
|
||||
userId: string | undefined,
|
||||
memberRole: string | undefined,
|
||||
): boolean {
|
||||
return canAssignAgentToIssue(agent, {
|
||||
userId: userId ?? null,
|
||||
role: memberRole === "owner" || memberRole === "admin" || memberRole === "member"
|
||||
? memberRole
|
||||
: null,
|
||||
}).allowed;
|
||||
}
|
||||
|
||||
export function AssigneePicker({
|
||||
@@ -147,12 +159,22 @@ export function AssigneePicker({
|
||||
{filteredAgents.length > 0 && (
|
||||
<PickerSection label="Agents">
|
||||
{filteredAgents.map((a) => {
|
||||
const allowed = canAssignAgent(a, user?.id, memberRole);
|
||||
const decision = canAssignAgentToIssue(a, {
|
||||
userId: user?.id ?? null,
|
||||
role:
|
||||
memberRole === "owner" ||
|
||||
memberRole === "admin" ||
|
||||
memberRole === "member"
|
||||
? memberRole
|
||||
: null,
|
||||
});
|
||||
const allowed = decision.allowed;
|
||||
return (
|
||||
<PickerItem
|
||||
key={a.id}
|
||||
selected={isSelected("agent", a.id)}
|
||||
disabled={!allowed}
|
||||
tooltip={!allowed ? decision.message : undefined}
|
||||
onClick={() => {
|
||||
if (!allowed) return;
|
||||
onUpdate({
|
||||
|
||||
@@ -49,6 +49,9 @@ export function DeleteIssueConfirmModal({
|
||||
<AlertDialogTitle>Delete issue</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this issue and all its comments. This action cannot be undone.
|
||||
<span className="mt-2 block text-xs text-muted-foreground/80">
|
||||
Any workspace member can delete issues.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -118,11 +118,16 @@ export function createRuntimeColumns({
|
||||
size: COL_WIDTHS.owner,
|
||||
cell: ({ row }) =>
|
||||
row.original.ownerMember ? (
|
||||
<ActorAvatar
|
||||
actorType="member"
|
||||
actorId={row.original.ownerMember.user_id}
|
||||
size={18}
|
||||
/>
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<ActorAvatar
|
||||
actorType="member"
|
||||
actorId={row.original.ownerMember.user_id}
|
||||
size={18}
|
||||
/>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{row.original.ownerMember.name}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/50">—</span>
|
||||
),
|
||||
@@ -510,6 +515,7 @@ function RowMenu({
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
title="Only the runtime owner and workspace admins can delete this runtime"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
@@ -529,6 +535,9 @@ function RowMenu({
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete “{runtime.name}”?
|
||||
This action cannot be undone.
|
||||
<span className="mt-2 block text-xs text-muted-foreground/80">
|
||||
Only the runtime owner and workspace admins can delete a runtime.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -53,6 +53,7 @@ function MemberRow({
|
||||
member,
|
||||
canManage,
|
||||
canManageOwners,
|
||||
ownerCount,
|
||||
isSelf,
|
||||
busy,
|
||||
onRoleChange,
|
||||
@@ -61,6 +62,9 @@ function MemberRow({
|
||||
member: MemberWithUser;
|
||||
canManage: boolean;
|
||||
canManageOwners: boolean;
|
||||
/** Total number of owners in this workspace — needed to gate demoting the
|
||||
* last owner per `workspace.go:497-507`. */
|
||||
ownerCount: number;
|
||||
isSelf: boolean;
|
||||
busy: boolean;
|
||||
onRoleChange: (role: MemberRole) => void;
|
||||
@@ -70,6 +74,7 @@ function MemberRow({
|
||||
const RoleIcon = rc.icon;
|
||||
const canEditRole = canManage && !isSelf && (member.role !== "owner" || canManageOwners);
|
||||
const canRemove = canManage && !isSelf && (member.role !== "owner" || canManageOwners);
|
||||
const isLastOwner = member.role === "owner" && ownerCount <= 1;
|
||||
const showMenu = canEditRole || canRemove;
|
||||
|
||||
return (
|
||||
@@ -100,16 +105,31 @@ function MemberRow({
|
||||
([role, config]) => {
|
||||
if (role === "owner" && !canManageOwners) return null;
|
||||
const Icon = config.icon;
|
||||
// Demoting the last owner would leave the workspace
|
||||
// ownerless — server rejects with 400, mirror that
|
||||
// here as a disabled option with explanation.
|
||||
const wouldDemoteLastOwner =
|
||||
isLastOwner && role !== "owner";
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={role}
|
||||
onClick={() => onRoleChange(role)}
|
||||
onClick={() =>
|
||||
wouldDemoteLastOwner ? undefined : onRoleChange(role)
|
||||
}
|
||||
disabled={wouldDemoteLastOwner}
|
||||
title={
|
||||
wouldDemoteLastOwner
|
||||
? "Promote another member to owner first — a workspace must keep at least one owner."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<div className="flex flex-col">
|
||||
<span>{config.label}</span>
|
||||
<span className="text-xs text-muted-foreground font-normal">
|
||||
{config.description}
|
||||
{wouldDemoteLastOwner
|
||||
? "Cannot demote the last owner"
|
||||
: config.description}
|
||||
</span>
|
||||
</div>
|
||||
{member.role === role && (
|
||||
@@ -206,6 +226,7 @@ export function MembersTab() {
|
||||
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
|
||||
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
|
||||
const isOwner = currentMember?.role === "owner";
|
||||
const ownerCount = members.filter((m) => m.role === "owner").length;
|
||||
|
||||
const handleInviteMember = async () => {
|
||||
if (!workspace) return;
|
||||
@@ -337,6 +358,7 @@ export function MembersTab() {
|
||||
member={m}
|
||||
canManage={canManageWorkspace}
|
||||
canManageOwners={isOwner}
|
||||
ownerCount={ownerCount}
|
||||
isSelf={m.user_id === user?.id}
|
||||
busy={memberActionId === m.id}
|
||||
onRoleChange={(role) => handleRoleChange(m.id, role)}
|
||||
|
||||
@@ -58,6 +58,8 @@ import {
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import { AppLink, useNavigation } from "../../navigation";
|
||||
import { useCanEditSkill } from "../hooks/use-can-edit-skill";
|
||||
import { useSkillPermissions } from "@multica/core/permissions";
|
||||
import { CapabilityBanner } from "@multica/ui/components/common/capability-banner";
|
||||
import { readOrigin, totalFileCount, type OriginInfo } from "../lib/origin";
|
||||
import { FileTree } from "./file-tree";
|
||||
import { FileViewer } from "./file-viewer";
|
||||
@@ -259,6 +261,9 @@ export function SkillDetailPage({ skillId }: { skillId: string }) {
|
||||
);
|
||||
|
||||
const canEdit = useCanEditSkill(skill, wsId);
|
||||
// Rich Decision for the read-only banner — same answer as `canEdit`, but
|
||||
// carries the `reason` enum the banner needs to render correct copy.
|
||||
const skillPermissions = useSkillPermissions(skill ?? null, wsId);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
@@ -608,6 +613,16 @@ export function SkillDetailPage({ skillId }: { skillId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canEdit && (
|
||||
<div className="px-4 pt-3">
|
||||
<CapabilityBanner
|
||||
reason={skillPermissions.canEdit.reason}
|
||||
resource="skill"
|
||||
ownerName={creator?.name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supporting query error banner (non-blocking — the page still works
|
||||
but agent attribution / runtime names / permission checks are
|
||||
partial). */}
|
||||
|
||||
Reference in New Issue
Block a user