From 405d88c1dc173d7399f94ec945e626a19c207c70 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:51:54 +0800 Subject: [PATCH] feat(squad): allow members to create and manage their own squads (MUL-4223) (#5071) Squad create/manage was gated behind workspace owner/admin, inconsistent with agents and projects which any member can create. Move squads to a creator-scoped model: any member can create a squad and becomes its creator, and manages only the squads they created; owner/admin continue to manage every squad. Backend (server/internal/handler/squad.go): - Add canManageSquad (admin/owner OR creator) and gate UpdateSquad, DeleteSquad, AddSquadMember, RemoveSquadMember, UpdateSquadMemberRole on it (member load + squad load + per-squad check, replacing requireWorkspaceRole). - CreateSquad is now member-creatable. - Add memberCanWireAgent: a non-admin may only wire agents they can @-trigger (canInvokeAgent as themselves) as squad leader (create/update) or worker (add member); admins may wire any workspace agent. Prevents a creator from smuggling an agent they cannot invoke into a squad. Frontend: - squad-detail-page: compute per-squad canManage (admin || creator) and render the inspector, members tab, instructions and archive read-only otherwise, mirroring the agent detail canEdit pattern. - squads-page: per-row actions and the actions column now key off per-squad canManage instead of workspace-admin. Squads stay visible workspace-wide (ListSquads unfiltered); creator transfer is out of scope for this iteration. Co-authored-by: J Co-authored-by: multica-agent --- .../squads/components/squad-detail-page.tsx | 218 ++++++++++++----- .../views/squads/components/squads-page.tsx | 15 +- server/internal/handler/squad.go | 100 +++++++- .../handler/squad_creator_scope_test.go | 222 ++++++++++++++++++ 4 files changed, 480 insertions(+), 75 deletions(-) create mode 100644 server/internal/handler/squad_creator_scope_test.go diff --git a/packages/views/squads/components/squad-detail-page.tsx b/packages/views/squads/components/squad-detail-page.tsx index 5c74a655ae..92d5269542 100644 --- a/packages/views/squads/components/squad-detail-page.tsx +++ b/packages/views/squads/components/squad-detail-page.tsx @@ -103,19 +103,26 @@ export function SquadDetailPage() { const { data: wsMembers = [] } = useQuery(memberListOptions(wsId)); // Runtimes are only fetched when the Create Agent dialog might open; - // gating on isWorkspaceAdmin below means non-admins never trigger the - // request. The runtime list mirrors the agents page so the picker - // (and the "only my runtimes" filter) behaves identically here. + // gating on canManage below means users who can't manage this squad never + // trigger the request. The runtime list mirrors the agents page so the + // picker (and the "only my runtimes" filter) behaves identically here. const currentUser = useAuthStore((s) => s.user); const myRole = useMemo(() => { if (!currentUser) return null; return wsMembers.find((m) => m.user_id === currentUser.id)?.role ?? null; }, [wsMembers, currentUser]); const isWorkspaceAdmin = myRole === "owner" || myRole === "admin"; + // Per-squad management gate: workspace owner/admin manage every squad; the + // creator manages the squads they created. Mirrors canManageSquad in + // server/internal/handler/squad.go so editable controls appear exactly when + // the API will accept the write, and everyone else gets a read-only view + // instead of controls that 403 (MUL-4223). + const canManage = + isWorkspaceAdmin || (!!currentUser && squad?.creator_id === currentUser.id); const { data: runtimes = [], isLoading: runtimesLoading } = useQuery({ ...runtimeListOptions(wsId), - enabled: !!wsId && isWorkspaceAdmin, + enabled: !!wsId && canManage, }); const [showAddMember, setShowAddMember] = useState(false); @@ -233,10 +240,12 @@ export function SquadDetailPage() { } actions={ - + canManage ? ( + + ) : null } /> @@ -249,6 +258,7 @@ export function SquadDetailPage() { memberCount={members.length} leaderName={getEntityName("agent", squad.leader_id)} creatorName={getEntityName("member", squad.creator_id)} + canManage={canManage} uploadingAvatar={updateSquadMut.isPending} onUploadAvatar={(url) => updateSquadMut.mutateAsync({ avatar_url: url })} onRename={async (next) => { await updateSquadMut.mutateAsync({ name: next.trim() }); }} @@ -259,11 +269,12 @@ export function SquadDetailPage() { squad={squad} members={members} memberStatusById={memberStatusById} + canManage={canManage} isLeader={isLeader} isArchived={isArchived} getEntityName={getEntityName} onAddMemberClick={() => setShowAddMember(true)} - onCreateAgentClick={isWorkspaceAdmin ? () => setShowCreateAgent(true) : undefined} + onCreateAgentClick={canManage ? () => setShowCreateAgent(true) : undefined} onSetLeader={(id) => setLeaderMut.mutate(id)} onRemoveMember={(m) => removeMemberMut.mutate(m)} onUpdateRole={async (m, role) => { await updateRoleMut.mutateAsync({ member: m, role }); }} @@ -284,10 +295,11 @@ export function SquadDetailPage() { {/* Squad-scoped create flow: same dialog as the Agents page but with squadId set, so the dialog runs api.addSquadMember after api.createAgent and skips the agent-detail navigation. Only - mounted for workspace owner/admin since AddSquadMember is - owner/admin-gated server-side; for everyone else the trigger - never renders. */} - {showCreateAgent && isWorkspaceAdmin && ( + mounted for users who can manage this squad (workspace owner/admin + or the creator); for everyone else the trigger never renders. The + newly created agent is owned by the creator, so it is always one + they can invoke and add to the squad. */} + {showCreateAgent && canManage && ( + {squad.avatar_url ? ( + + ) : ( +
+ +
+ )} + + ); +} + // Inline name editor — reveals a Pencil affordance on hover, opens a small // popover with a single-line input. Mirrors the NameAndDescription editor // in the agent inspector. @@ -802,6 +836,7 @@ function SquadDetailInspector({ memberCount, leaderName, creatorName, + canManage, uploadingAvatar, onUploadAvatar, onRename, @@ -811,6 +846,10 @@ function SquadDetailInspector({ memberCount: number; leaderName: string; creatorName: string; + // When false the identity block renders as static text (no avatar upload, + // no rename/description popovers) — the viewer can read the squad but not + // edit it. Mirrors the agent inspector's `canEdit` read-only treatment. + canManage: boolean; uploadingAvatar: boolean; onUploadAvatar: (url: string) => Promise; onRename: (next: string) => Promise; @@ -829,19 +868,39 @@ function SquadDetailInspector({