mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +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>
91 lines
2.7 KiB
TypeScript
91 lines
2.7 KiB
TypeScript
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
|
|
}
|
|
}
|