From e1a0b0ffcc3cd76c2a7d13b2dcb33292e577bf2a Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Sat, 15 Jun 2024 15:34:18 -0500 Subject: [PATCH 1/6] docs: add screenshots page (#13582) * docs: add screenshots page * fmt From 3b60247671b6ad59a6b68eb7f1e77deaa84119f1 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 14 Jun 2024 12:01:04 -0500 Subject: [PATCH 2/6] chore: add organization member api + cli --- cli/organization.go | 2 +- cli/organizationmembers.go | 31 ++++++++++++++++++++++++ coderd/coderd.go | 19 +++++++++++---- coderd/members.go | 48 ++++++++++++++++++++++++++++++++++++++ codersdk/users.go | 14 +++++++++++ 5 files changed, 108 insertions(+), 6 deletions(-) diff --git a/cli/organization.go b/cli/organization.go index 36ea0737812b0..44f9c3308139e 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -29,8 +29,8 @@ func (r *RootCmd) organizations() *serpent.Command { r.currentOrganization(), r.switchOrganization(), r.createOrganization(), - r.organizationRoles(), r.organizationMembers(), + r.organizationRoles(), }, } diff --git a/cli/organizationmembers.go b/cli/organizationmembers.go index d81f08f333474..0c5cef1cb66ec 100644 --- a/cli/organizationmembers.go +++ b/cli/organizationmembers.go @@ -19,6 +19,7 @@ func (r *RootCmd) organizationMembers() *serpent.Command { Children: []*serpent.Command{ r.listOrganizationMembers(), r.assignOrganizationRoles(), + r.addOrganizationMember(), }, Handler: func(inv *serpent.Invocation) error { return inv.Command.HelpHandler(inv) @@ -28,6 +29,36 @@ func (r *RootCmd) organizationMembers() *serpent.Command { return cmd } +func (r *RootCmd) addOrganizationMember() *serpent.Command { + client := new(codersdk.Client) + + cmd := &serpent.Command{ + Use: "add ", + Short: "Add a new member to the current organization", + Middleware: serpent.Chain( + r.InitClient(client), + serpent.RequireNArgs(1), + ), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + organization, err := CurrentOrganization(r, inv, client) + if err != nil { + return err + } + user := inv.Args[0] + _, err = client.PostOrganizationMember(ctx, organization.ID, user) + if err != nil { + return xerrors.Errorf("could not add member to organization: %w", err) + } + + _, _ = fmt.Fprintln(inv.Stdout, "Organization member added") + return nil + }, + } + + return cmd +} + func (r *RootCmd) assignOrganizationRoles() *serpent.Command { client := new(codersdk.Client) diff --git a/coderd/coderd.go b/coderd/coderd.go index e8a698de0de34..04cfe4720bec4 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -845,11 +845,20 @@ func New(options *Options) *API { }) r.Route("/{user}", func(r chi.Router) { - r.Use( - httpmw.ExtractOrganizationMemberParam(options.Database), - ) - r.Put("/roles", api.putMemberRoles) - r.Post("/workspaces", api.postWorkspacesByOrganization) + r.Group(func(r chi.Router) { + r.Use( + httpmw.ExtractUserParam(options.Database), + ) + r.Post("/", api.postOrganizationMember) + }) + + r.Group(func(r chi.Router) { + r.Use( + httpmw.ExtractOrganizationMemberParam(options.Database), + ) + r.Put("/roles", api.putMemberRoles) + r.Post("/workspaces", api.postWorkspacesByOrganization) + }) }) }) }) diff --git a/coderd/members.go b/coderd/members.go index bd41dfa10741a..10fec39d7e14b 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -9,12 +9,60 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" ) +// @Summary Add organization member +// @ID add-organization-member +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Members +// @Param organization path string true "Organization ID" +// @Param user path string true "User ID, name, or me" +// @Success 200 {object} codersdk.OrganizationMember +// @Router /organizations/{organization}/members/{user} [post] +func (api *API) postOrganizationMember(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + organization = httpmw.OrganizationParam(r) + user = httpmw.UserParam(r) + ) + + member, err := api.Database.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{ + OrganizationID: organization.ID, + UserID: user.ID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + Roles: []string{}, + }) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + resp, err := convertOrganizationMembers(ctx, api.Database, []database.OrganizationMember{member}) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + if len(resp) == 0 { + httpapi.InternalServerError(rw, xerrors.Errorf("marshal member")) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, resp[0]) +} + // @Summary List organization members // @ID list-organization-members // @Security CoderSessionToken diff --git a/codersdk/users.go b/codersdk/users.go index f16780aa2eb7c..5cf01405af0d4 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -379,6 +379,20 @@ func (c *Client) UpdateUserPassword(ctx context.Context, user string, req Update return nil } +// PostOrganizationMember adds a user to an organization +func (c *Client) PostOrganizationMember(ctx context.Context, organizationID uuid.UUID, user string) (OrganizationMember, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/members/%s", organizationID, user), nil) + if err != nil { + return OrganizationMember{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return OrganizationMember{}, ReadBodyAsError(res) + } + var member OrganizationMember + 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) ([]OrganizationMemberWithName, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/", organizationID), nil) From 9aff25da6b2c65e56614f965fd5715f0071b2882 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 17 Jun 2024 09:48:12 -0500 Subject: [PATCH 3/6] permissions comment --- cli/organizationmembers.go | 1 + coderd/coderd.go | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/cli/organizationmembers.go b/cli/organizationmembers.go index 0c5cef1cb66ec..e5754fda7220b 100644 --- a/cli/organizationmembers.go +++ b/cli/organizationmembers.go @@ -46,6 +46,7 @@ func (r *RootCmd) addOrganizationMember() *serpent.Command { return err } user := inv.Args[0] + _, err = client.PostOrganizationMember(ctx, organization.ID, user) if err != nil { return xerrors.Errorf("could not add member to organization: %w", err) diff --git a/coderd/coderd.go b/coderd/coderd.go index 04cfe4720bec4..7a697b58b7929 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -847,6 +847,10 @@ func New(options *Options) *API { r.Route("/{user}", func(r chi.Router) { r.Group(func(r chi.Router) { r.Use( + // Adding a member requires "read" permission + // on the site user. So limited to owners and user-admins. + // TODO: Allow org-admins to add users via some new permission? Or give them + // read on site users. httpmw.ExtractUserParam(options.Database), ) r.Post("/", api.postOrganizationMember) From 2db1953118d80a370ad57aad90ac4fdfc703e891 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 17 Jun 2024 10:04:45 -0500 Subject: [PATCH 4/6] Make gen --- coderd/apidoc/docs.go | 44 ++++++++++++++++++++++++++++++++++ coderd/apidoc/swagger.json | 38 ++++++++++++++++++++++++++++++ docs/api/members.md | 48 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 43ab78ffc1eeb..caf851fb480ff 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2356,6 +2356,50 @@ const docTemplate = `{ } } }, + "/organizations/{organization}/members/{user}": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Members" + ], + "summary": "Add organization member", + "operationId": "add-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.OrganizationMember" + } + } + } + } + }, "/organizations/{organization}/members/{user}/roles": { "put": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 8871bcf89e502..db073a95869f0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2062,6 +2062,44 @@ } } }, + "/organizations/{organization}/members/{user}": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Members"], + "summary": "Add organization member", + "operationId": "add-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.OrganizationMember" + } + } + } + } + }, "/organizations/{organization}/members/{user}/roles": { "put": { "security": [ diff --git a/docs/api/members.md b/docs/api/members.md index 77ef260131e29..2f0c8a97a9892 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -315,6 +315,54 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Add organization member + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/members/{user} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /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 +{ + "created_at": "2019-08-24T14:15:22Z", + "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" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OrganizationMember](schemas.md#codersdkorganizationmember) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Assign role to organization member ### Code samples From 1002c6bf404b361467e95650c9bf905341274d7f Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 17 Jun 2024 10:21:36 -0500 Subject: [PATCH 5/6] add accept header comment --- coderd/members.go | 1 - 1 file changed, 1 deletion(-) diff --git a/coderd/members.go b/coderd/members.go index 10fec39d7e14b..d958f401bb9b3 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -19,7 +19,6 @@ import ( // @Summary Add organization member // @ID add-organization-member // @Security CoderSessionToken -// @Accept json // @Produce json // @Tags Members // @Param organization path string true "Organization ID" From ce367e5e6a8a8592e0b8be3598e1ba8e41b334de Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 17 Jun 2024 11:31:34 -0500 Subject: [PATCH 6/6] make gen --- cli/organizationmembers_test.go | 38 +++++++++++++++++++++ coderd/apidoc/docs.go | 3 -- coderd/apidoc/swagger.json | 1 - coderd/members_test.go | 59 +++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 4 deletions(-) diff --git a/cli/organizationmembers_test.go b/cli/organizationmembers_test.go index 6cd8b9d3ccd4a..89c10e8cf2e92 100644 --- a/cli/organizationmembers_test.go +++ b/cli/organizationmembers_test.go @@ -9,6 +9,7 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -34,3 +35,40 @@ func TestListOrganizationMembers(t *testing.T) { require.Contains(t, buf.String(), owner.UserID.String()) }) } + +func TestAddOrganizationMembers(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + ownerClient := coderdtest.New(t, &coderdtest.Options{}) + owner := coderdtest.CreateFirstUser(t, ownerClient) + _, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitMedium) + //nolint:gocritic // must be an owner, only owners can create orgs + otherOrg, err := ownerClient.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "Other", + DisplayName: "", + Description: "", + Icon: "", + }) + require.NoError(t, err, "create another organization") + + inv, root := clitest.New(t, "organization", "members", "add", "--organization", otherOrg.ID.String(), user.Username) + //nolint:gocritic // must be an owner + clitest.SetupConfig(t, ownerClient, root) + + buf := new(bytes.Buffer) + inv.Stdout = buf + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + //nolint:gocritic // must be an owner + members, err := ownerClient.OrganizationMembers(ctx, otherOrg.ID) + require.NoError(t, err) + + require.Len(t, members, 2) + }) +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index caf851fb480ff..a04b57a1b5fac 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2363,9 +2363,6 @@ const docTemplate = `{ "CoderSessionToken": [] } ], - "consumes": [ - "application/json" - ], "produces": [ "application/json" ], diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index db073a95869f0..ed44db838e375 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2069,7 +2069,6 @@ "CoderSessionToken": [] } ], - "consumes": ["application/json"], "produces": ["application/json"], "tags": ["Members"], "summary": "Add organization member", diff --git a/coderd/members_test.go b/coderd/members_test.go index 250a594a150f5..1f7e0ff56ae09 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -13,6 +13,65 @@ import ( "github.com/coder/coder/v2/testutil" ) +func TestAddMember(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + owner := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, owner) + ctx := testutil.Context(t, testutil.WaitMedium) + org, err := owner.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "other", + DisplayName: "", + Description: "", + Icon: "", + }) + require.NoError(t, err) + + // Make a user not in the second organization + _, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID) + + members, err := owner.OrganizationMembers(ctx, org.ID) + require.NoError(t, err) + require.Len(t, members, 1) // Verify just the 1 member + + // Add user to org + _, err = owner.PostOrganizationMember(ctx, org.ID, user.Username) + require.NoError(t, err) + + members, err = owner.OrganizationMembers(ctx, org.ID) + require.NoError(t, err) + // Owner + new member + require.Len(t, members, 2) + require.ElementsMatch(t, + []uuid.UUID{first.UserID, user.ID}, + db2sdk.List(members, onlyIDs)) + }) + + t.Run("UserNotExists", func(t *testing.T) { + t.Parallel() + owner := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, owner) + ctx := testutil.Context(t, testutil.WaitMedium) + + org, err := owner.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "other", + DisplayName: "", + Description: "", + Icon: "", + }) + require.NoError(t, err) + + // Add user to org + _, err = owner.PostOrganizationMember(ctx, org.ID, uuid.NewString()) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Contains(t, apiErr.Message, "must be an existing") + }) +} + func TestListMembers(t *testing.T) { t.Parallel()