Skip to content

feat: add PUT /api/v2/users/:user-id/suspend endpoint #1154

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 26, 2022
1 change: 1 addition & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ func New(options *Options) (http.Handler, func()) {
r.Use(httpmw.ExtractUserParam(options.Database))
r.Get("/", api.userByName)
r.Put("/profile", api.putUserProfile)
r.Put("/suspend", api.putUserSuspend)
r.Get("/organizations", api.organizationsByUser)
r.Post("/organizations", api.postOrganizationsByUser)
r.Post("/keys", api.postAPIKey)
Expand Down
17 changes: 17 additions & 0 deletions coderd/database/databasefake/databasefake.go
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,7 @@ func (q *fakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam
CreatedAt: arg.CreatedAt,
UpdatedAt: arg.UpdatedAt,
Username: arg.Username,
Status: database.UserStatusTypeActive,
}
q.users = append(q.users, user)
return user, nil
Expand All @@ -1139,6 +1140,22 @@ func (q *fakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUs
return database.User{}, sql.ErrNoRows
}

func (q *fakeQuerier) UpdateUserStatus(_ context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
q.mutex.Lock()
defer q.mutex.Unlock()

for index, user := range q.users {
if user.ID != arg.ID {
continue
}
user.Status = arg.Status
user.UpdatedAt = arg.UpdatedAt
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()
Expand Down
8 changes: 7 additions & 1 deletion coderd/database/dump.sql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions coderd/database/migrations/000007_user_status.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
DROP TYPE user_status_type;

ALTER TABLE ONLY users
DROP COLUMN IF EXISTS status;
4 changes: 4 additions & 0 deletions coderd/database/migrations/000007_user_status.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
CREATE TYPE user_status_type AS ENUM ('active', 'suspended');

ALTER TABLE ONLY users
ADD COLUMN IF NOT EXISTS status user_status_type NOT NULL DEFAULT 'active';
32 changes: 26 additions & 6 deletions coderd/database/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 41 additions & 5 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions coderd/database/queries/users.sql
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,12 @@ ORDER BY
LIMIT
-- A null limit means "no limit", so -1 means return all
NULLIF(@limit_opt :: int, -1);

-- name: UpdateUserStatus :one
UPDATE
users
SET
status = $2,
updated_at = $3
WHERE
id = $1 RETURNING *;
20 changes: 20 additions & 0 deletions coderd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,25 @@ func (api *api) putUserProfile(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(rw, http.StatusOK, convertUser(updatedUserProfile))
}

func (api *api) putUserSuspend(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)

suspendedUser, err := api.Database.UpdateUserStatus(r.Context(), database.UpdateUserStatusParams{
ID: user.ID,
Status: database.UserStatusTypeSuspended,
UpdatedAt: database.Now(),
})

if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("put user suspended: %s", err.Error()),
})
return
}

httpapi.Write(rw, http.StatusOK, convertUser(suspendedUser))
}

// Returns organizations the parameterized user has access to.
func (api *api) organizationsByUser(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)
Expand Down Expand Up @@ -942,6 +961,7 @@ func convertUser(user database.User) codersdk.User {
Email: user.Email,
CreatedAt: user.CreatedAt,
Username: user.Username,
Status: codersdk.UserStatus(user.Status),
}
}

Expand Down
32 changes: 32 additions & 0 deletions coderd/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,38 @@ func TestUpdateUserProfile(t *testing.T) {
})
}

func TestPutUserSuspend(t *testing.T) {
t.Parallel()

t.Run("SuspendAnotherUser", func(t *testing.T) {
t.Skip()
t.Parallel()
client := coderdtest.New(t, nil)
me := coderdtest.CreateFirstUser(t, client)
client.User(context.Background(), codersdk.Me)
user, err := client.CreateUser(context.Background(), codersdk.CreateUserRequest{
Email: "bruno@coder.com",
Username: "bruno",
Password: "password",
OrganizationID: me.OrganizationID,
})
user, err = client.SuspendUser(context.Background(), user.ID)
require.NoError(t, err)
require.Equal(t, user.Status, codersdk.UserStatusSuspended)
})

t.Run("SuspendItSelf", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
client.User(context.Background(), codersdk.Me)
suspendedUser, err := client.SuspendUser(context.Background(), codersdk.Me)

require.NoError(t, err)
require.Equal(t, suspendedUser.Status, codersdk.UserStatusSuspended)
})
}

func TestUserByName(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
Expand Down
30 changes: 26 additions & 4 deletions codersdk/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,20 @@ type UsersRequest struct {
Offset int `json:"offset"`
}

type UserStatus string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of UserStatus I'd be more in favor of @johnstcn's suggestion to use suspended as a boolean. Because we only have two statuses for now, it feels weird to make it generic.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is this conversation here https://codercom.slack.com/archives/C014JH42DBJ/p1650895472652489?thread_ts=1650895472.652489&cid=C014JH42DBJ but I'm good using both.

Ah, I missed parts of that thread. I'm fine either way honestly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kylecarbs @johnstcn I think we will have more statuses like dormant

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could a user be dormant and suspended?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kylecarbs I believe the user would just be suspended.

-- Just my take --
Only active users count towards the licensed seats. A dormant user is "inactive" based on activity. A dormant user can reactive themselves by just being active again. (Maybe they have to do some more steps, idk).

A suspended user cannot even log in. Their account must be reactivated by an admin. The user has 0 input on this.


const (
UserStatusActive UserStatus = "active"
UserStatusSuspended UserStatus = "suspended"
)

// User represents a user in Coder.
type User struct {
ID uuid.UUID `json:"id" validate:"required"`
Email string `json:"email" validate:"required"`
CreatedAt time.Time `json:"created_at" validate:"required"`
Username string `json:"username" validate:"required"`
ID uuid.UUID `json:"id" validate:"required"`
Email string `json:"email" validate:"required"`
CreatedAt time.Time `json:"created_at" validate:"required"`
Username string `json:"username" validate:"required"`
Status UserStatus `json:"status"`
}

type CreateFirstUserRequest struct {
Expand Down Expand Up @@ -155,6 +163,20 @@ func (c *Client) UpdateUserProfile(ctx context.Context, userID uuid.UUID, req Up
return user, json.NewDecoder(res.Body).Decode(&user)
}

// SuspendUser enables callers to suspend a user
func (c *Client) SuspendUser(ctx context.Context, userID uuid.UUID) (User, error) {
res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/suspend", uuidOrMe(userID)), nil)
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)
Expand Down
Loading