From 63d14650199462f28b34b661aed1122d0950aee3 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 12 Apr 2022 11:05:21 -0300 Subject: [PATCH] feat: Add update profile endpoint (#916) --- coderd/coderd.go | 1 + coderd/database/databasefake/databasefake.go | 17 +++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 43 ++++++++ coderd/database/queries/users.sql | 11 ++ coderd/users.go | 65 ++++++++++++ coderd/users_test.go | 105 +++++++++++++++++++ codersdk/users.go | 21 ++++ 8 files changed, 264 insertions(+) diff --git a/coderd/coderd.go b/coderd/coderd.go index 3a6e3c2d93..83bb6ec78e 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -150,6 +150,7 @@ func New(options *Options) (http.Handler, func()) { r.Route("/{user}", func(r chi.Router) { r.Use(httpmw.ExtractUserParam(options.Database)) r.Get("/", api.userByName) + r.Put("/profile", api.putUserProfile) r.Get("/organizations", api.organizationsByUser) r.Post("/organizations", api.postOrganizationsByUser) r.Post("/keys", api.postAPIKey) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 45f652d038..36b1d822db 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1050,6 +1050,23 @@ func (q *fakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam return user, nil } +func (q *fakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUserProfileParams) (database.User, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, user := range q.users { + if user.ID != arg.ID { + continue + } + user.Name = arg.Name + user.Email = arg.Email + user.Username = arg.Username + q.users[index] = user + return user, nil + } + return database.User{}, sql.ErrNoRows +} + func (q *fakeQuerier) InsertWorkspace(_ context.Context, arg database.InsertWorkspaceParams) (database.Workspace, error) { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 120da839a1..073113a245 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -81,6 +81,7 @@ type querier interface { UpdateTemplateActiveVersionByID(ctx context.Context, arg UpdateTemplateActiveVersionByIDParams) error UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error + UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error UpdateWorkspaceAutostop(ctx context.Context, arg UpdateWorkspaceAutostopParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b37c1e53c7..e816b90c57 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1905,6 +1905,49 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User return i, err } +const updateUserProfile = `-- name: UpdateUserProfile :one +UPDATE + users +SET + email = $2, + "name" = $3, + username = $4, + updated_at = $5 +WHERE + id = $1 RETURNING id, email, name, revoked, login_type, hashed_password, created_at, updated_at, username +` + +type UpdateUserProfileParams struct { + ID uuid.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Name string `db:"name" json:"name"` + Username string `db:"username" json:"username"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) { + row := q.db.QueryRowContext(ctx, updateUserProfile, + arg.ID, + arg.Email, + arg.Name, + arg.Username, + arg.UpdatedAt, + ) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.Name, + &i.Revoked, + &i.LoginType, + &i.HashedPassword, + &i.CreatedAt, + &i.UpdatedAt, + &i.Username, + ) + return i, err +} + const getWorkspaceAgentByAuthToken = `-- name: GetWorkspaceAgentByAuthToken :one SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 08589699be..f41a96877d 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -40,3 +40,14 @@ INSERT INTO ) VALUES ($1, $2, $3, $4, FALSE, $5, $6, $7, $8) RETURNING *; + +-- name: UpdateUserProfile :one +UPDATE + users +SET + email = $2, + "name" = $3, + username = $4, + updated_at = $5 +WHERE + id = $1 RETURNING *; diff --git a/coderd/users.go b/coderd/users.go index 8f2f06d09d..e9a93d0f2a 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -270,6 +270,70 @@ func (*api) userByName(rw http.ResponseWriter, r *http.Request) { render.JSON(rw, r, convertUser(user)) } +func (api *api) putUserProfile(rw http.ResponseWriter, r *http.Request) { + user := httpmw.UserParam(r) + + var params codersdk.UpdateUserProfileRequest + if !httpapi.Read(rw, r, ¶ms) { + return + } + + if params.Name == nil { + params.Name = &user.Name + } + + existentUser, err := api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ + Email: params.Email, + Username: params.Username, + }) + isDifferentUser := existentUser.ID != user.ID + + if err == nil && isDifferentUser { + responseErrors := []httpapi.Error{} + if existentUser.Email == params.Email { + responseErrors = append(responseErrors, httpapi.Error{ + Field: "email", + Code: "exists", + }) + } + if existentUser.Username == params.Username { + responseErrors = append(responseErrors, httpapi.Error{ + Field: "username", + Code: "exists", + }) + } + httpapi.Write(rw, http.StatusConflict, httpapi.Response{ + Message: fmt.Sprintf("user already exists"), + Errors: responseErrors, + }) + return + } + if !errors.Is(err, sql.ErrNoRows) && isDifferentUser { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get user: %s", err), + }) + return + } + + updatedUserProfile, err := api.Database.UpdateUserProfile(r.Context(), database.UpdateUserProfileParams{ + ID: user.ID, + Name: *params.Name, + Email: params.Email, + Username: params.Username, + UpdatedAt: database.Now(), + }) + + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("patch user: %s", err.Error()), + }) + return + } + + render.Status(r, http.StatusOK) + render.JSON(rw, r, convertUser(updatedUserProfile)) +} + // Returns organizations the parameterized user has access to. func (api *api) organizationsByUser(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) @@ -872,5 +936,6 @@ func convertUser(user database.User) codersdk.User { Email: user.Email, CreatedAt: user.CreatedAt, Username: user.Username, + Name: user.Name, } } diff --git a/coderd/users_test.go b/coderd/users_test.go index 07ab1cff0e..d733f022ae 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -200,6 +200,111 @@ func TestPostUsers(t *testing.T) { }) } +func TestUpdateUserProfile(t *testing.T) { + t.Parallel() + t.Run("UserNotFound", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + _, err := client.UpdateUserProfile(context.Background(), uuid.New(), codersdk.UpdateUserProfileRequest{ + Username: "newusername", + Email: "newemail@coder.com", + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + // Right now, we are raising a BAD request error because we don't support a + // user accessing other users info + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + }) + + t.Run("ConflictingEmail", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + existentUser, _ := client.CreateUser(context.Background(), codersdk.CreateUserRequest{ + Email: "bruno@coder.com", + Username: "bruno", + Password: "password", + OrganizationID: user.OrganizationID, + }) + _, err := client.UpdateUserProfile(context.Background(), codersdk.Me, codersdk.UpdateUserProfileRequest{ + Username: "newusername", + Email: existentUser.Email, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusConflict, apiErr.StatusCode()) + }) + + t.Run("ConflictingUsername", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + existentUser, _ := client.CreateUser(context.Background(), codersdk.CreateUserRequest{ + Email: "bruno@coder.com", + Username: "bruno", + Password: "password", + OrganizationID: user.OrganizationID, + }) + _, err := client.UpdateUserProfile(context.Background(), codersdk.Me, codersdk.UpdateUserProfileRequest{ + Username: existentUser.Username, + Email: "newemail@coder.com", + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusConflict, apiErr.StatusCode()) + }) + + t.Run("UpdateUsernameAndEmail", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + userProfile, err := client.UpdateUserProfile(context.Background(), codersdk.Me, codersdk.UpdateUserProfileRequest{ + Username: "newusername", + Email: "newemail@coder.com", + }) + require.NoError(t, err) + require.Equal(t, userProfile.Username, "newusername") + require.Equal(t, userProfile.Email, "newemail@coder.com") + }) + + t.Run("UpdateUsername", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + me, _ := client.User(context.Background(), codersdk.Me) + userProfile, err := client.UpdateUserProfile(context.Background(), codersdk.Me, codersdk.UpdateUserProfileRequest{ + Username: me.Username, + Email: "newemail@coder.com", + }) + require.NoError(t, err) + require.Equal(t, userProfile.Username, me.Username) + require.Equal(t, userProfile.Email, "newemail@coder.com") + }) + + t.Run("KeepUserName", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + me, _ := client.User(context.Background(), codersdk.Me) + newName := "New Name" + firstProfile, _ := client.UpdateUserProfile(context.Background(), codersdk.Me, codersdk.UpdateUserProfileRequest{ + Username: me.Username, + Email: me.Email, + Name: &newName, + }) + t.Log(firstProfile) + userProfile, err := client.UpdateUserProfile(context.Background(), codersdk.Me, codersdk.UpdateUserProfileRequest{ + Username: "newusername", + Email: "newemail@coder.com", + }) + require.NoError(t, err) + require.Equal(t, userProfile.Username, "newusername") + require.Equal(t, userProfile.Email, "newemail@coder.com") + require.Equal(t, userProfile.Name, newName) + }) +} + func TestUserByName(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/codersdk/users.go b/codersdk/users.go index ff62779767..d6a920a4c4 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -19,6 +19,7 @@ type User struct { Email string `json:"email" validate:"required"` CreatedAt time.Time `json:"created_at" validate:"required"` Username string `json:"username" validate:"required"` + Name string `json:"name"` } type CreateFirstUserRequest struct { @@ -41,6 +42,12 @@ type CreateUserRequest struct { OrganizationID uuid.UUID `json:"organization_id" validate:"required"` } +type UpdateUserProfileRequest struct { + Email string `json:"email" validate:"required,email"` + Username string `json:"username" validate:"required,username"` + Name *string `json:"name"` +} + // LoginWithPasswordRequest enables callers to authenticate with email and password. type LoginWithPasswordRequest struct { Email string `json:"email" validate:"required,email"` @@ -115,6 +122,20 @@ func (c *Client) CreateUser(ctx context.Context, req CreateUserRequest) (User, e return user, json.NewDecoder(res.Body).Decode(&user) } +// UpdateUserProfile enables callers to update profile information +func (c *Client) UpdateUserProfile(ctx context.Context, userID uuid.UUID, req UpdateUserProfileRequest) (User, error) { + res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/profile", uuidOrMe(userID)), req) + if err != nil { + return User{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return User{}, readBodyAsError(res) + } + var user User + return user, json.NewDecoder(res.Body).Decode(&user) +} + // CreateAPIKey generates an API key for the user ID provided. func (c *Client) CreateAPIKey(ctx context.Context, userID uuid.UUID) (*GenerateAPIKeyResponse, error) { res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys", uuidOrMe(userID)), nil)