From 6759b51cd697a2f8e26f4cf0a49c55e7c6ccc6cf Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 3 Feb 2026 12:48:25 -0600 Subject: [PATCH] feat: add endpoint to fetch singular org member (#21732) --- coderd/apidoc/docs.go | 39 +++++++++++++++++++++++ coderd/apidoc/swagger.json | 35 +++++++++++++++++++++ coderd/coderd.go | 1 + coderd/members.go | 48 ++++++++++++++++++++++++++++ coderd/members_test.go | 24 +++++++++++--- codersdk/users.go | 13 ++++++++ docs/reference/api/members.md | 59 +++++++++++++++++++++++++++++++++++ 7 files changed, 215 insertions(+), 4 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index fd36565afa..f465e48f25 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3482,6 +3482,45 @@ const docTemplate = `{ } }, "/organizations/{organization}/members/{user}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Members" + ], + "summary": "Get organization member", + "operationId": "get-organization-member", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OrganizationMemberWithUserData" + } + } + } + }, "post": { "security": [ { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 0e190021a2..478582d8d7 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3059,6 +3059,41 @@ } }, "/organizations/{organization}/members/{user}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Members"], + "summary": "Get organization member", + "operationId": "get-organization-member", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OrganizationMemberWithUserData" + } + } + } + }, "post": { "security": [ { diff --git a/coderd/coderd.go b/coderd/coderd.go index 2efdbd361f..414459d0b7 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1228,6 +1228,7 @@ func New(options *Options) *API { r.Use( httpmw.ExtractOrganizationMemberParam(options.Database), ) + r.Get("/", api.organizationMember) r.Delete("/", api.deleteOrganizationMember) r.Put("/roles", api.putMemberRoles) r.Post("/workspaces", api.postWorkspacesByOrganization) diff --git a/coderd/members.go b/coderd/members.go index a98ba9c6fc..6fd1856950 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -145,6 +145,54 @@ func (api *API) deleteOrganizationMember(rw http.ResponseWriter, r *http.Request rw.WriteHeader(http.StatusNoContent) } +// @Summary Get organization member +// @ID get-organization-member +// @Security CoderSessionToken +// @Tags Members +// @Param organization path string true "Organization ID" +// @Param user path string true "User ID, name, or me" +// @Success 200 {object} codersdk.OrganizationMemberWithUserData +// @Produce json +// @Router /organizations/{organization}/members/{user} [get] +func (api *API) organizationMember(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + organization = httpmw.OrganizationParam(r) + member = httpmw.OrganizationMemberParam(r) + ) + + // This is unfortunate to fetch like this, but we need the user table data. + // The listing route uses this data format, so it is just easier to reuse the + // list query. + rows, err := api.Database.OrganizationMembers(ctx, database.OrganizationMembersParams{ + OrganizationID: organization.ID, + UserID: member.UserID, + IncludeSystem: false, + GithubUserID: 0, + }) + if httpapi.Is404Error(err) || len(rows) == 0 { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, rows) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + if len(resp) != 1 { + httpapi.InternalServerError(rw, xerrors.Errorf("unexpected organization members, something went wrong")) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, resp[0]) +} + // @Deprecated use /organizations/{organization}/paginated-members [get] // @Summary List organization members // @ID list-organization-members diff --git a/coderd/members_test.go b/coderd/members_test.go index 17612185cc..c7d9cad1da 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -18,17 +18,33 @@ import ( func TestAddMember(t *testing.T) { t.Parallel() + owner := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, owner) + _, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID) + t.Run("AlreadyMember", func(t *testing.T) { t.Parallel() - owner := coderdtest.New(t, nil) - first := coderdtest.CreateFirstUser(t, owner) - _, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID) - ctx := testutil.Context(t, testutil.WaitMedium) // Add user to org, even though they already exist // nolint:gocritic // must be an owner to see the user _, err := owner.PostOrganizationMember(ctx, first.OrganizationID, user.Username) require.ErrorContains(t, err, "already an organization member") + + org, err := owner.Organization(ctx, first.OrganizationID) + require.NoError(t, err) + + member, err := owner.OrganizationMember(ctx, org.Name, user.Username) + require.NoError(t, err) + require.Equal(t, member.UserID, user.ID) + }) + + t.Run("Me", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + member, err := owner.OrganizationMember(ctx, first.OrganizationID.String(), codersdk.Me) + require.NoError(t, err) + require.Equal(t, member.UserID, first.UserID) }) } diff --git a/codersdk/users.go b/codersdk/users.go index 1014625314..eccee428de 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -644,6 +644,19 @@ func OrganizationMembersQueryOptionGithubUserID(githubUserID int64) Organization } } +func (c *Client) OrganizationMember(ctx context.Context, organizationIdent, userIdent string) (OrganizationMemberWithUserData, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/%s", organizationIdent, userIdent), nil) + if err != nil { + return OrganizationMemberWithUserData{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return OrganizationMemberWithUserData{}, ReadBodyAsError(res) + } + var member OrganizationMemberWithUserData + return member, json.NewDecoder(res.Body).Decode(&member) +} + // OrganizationMembers lists all members in an organization func (c *Client) OrganizationMembers(ctx context.Context, organizationID uuid.UUID, opts ...OrganizationMembersQueryOption) ([]OrganizationMemberWithUserData, error) { var query OrganizationMembersQuery diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index 26669241ed..8704228ddc 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -540,6 +540,65 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get organization member + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members/{user} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /organizations/{organization}/members/{user}` + +### Parameters + +| Name | In | Type | Required | Description | +|----------------|------|--------|----------|----------------------| +| `organization` | path | string | true | Organization ID | +| `user` | path | string | true | User ID, name, or me | + +### Example responses + +> 200 Response + +```json +{ + "avatar_url": "string", + "created_at": "2019-08-24T14:15:22Z", + "email": "string", + "global_roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OrganizationMemberWithUserData](schemas.md#codersdkorganizationmemberwithuserdata) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Add organization member ### Code samples