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({