From 30b71d0739b1dbf356d458d7920759a7ba75c9a5 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 21 Jul 2023 13:45:27 +0200 Subject: [PATCH 01/69] WIP --- coderd/database/migrations/000141_user_status_dormant.down.sql | 3 +++ coderd/database/migrations/000141_user_status_dormant.up.sql | 2 ++ .../migrations/000142_user_status_default_dormant.down.sql | 1 + .../migrations/000142_user_status_default_dormant.up.sql | 1 + codersdk/users.go | 1 + 5 files changed, 8 insertions(+) create mode 100644 coderd/database/migrations/000141_user_status_dormant.down.sql create mode 100644 coderd/database/migrations/000141_user_status_dormant.up.sql create mode 100644 coderd/database/migrations/000142_user_status_default_dormant.down.sql create mode 100644 coderd/database/migrations/000142_user_status_default_dormant.up.sql diff --git a/coderd/database/migrations/000141_user_status_dormant.down.sql b/coderd/database/migrations/000141_user_status_dormant.down.sql new file mode 100644 index 0000000000000..2a471829b0075 --- /dev/null +++ b/coderd/database/migrations/000141_user_status_dormant.down.sql @@ -0,0 +1,3 @@ +-- It's not possible to drop enum values from enum types, so the UP has "IF NOT EXISTS" + +ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active'::user_status; diff --git a/coderd/database/migrations/000141_user_status_dormant.up.sql b/coderd/database/migrations/000141_user_status_dormant.up.sql new file mode 100644 index 0000000000000..106cc10b53b62 --- /dev/null +++ b/coderd/database/migrations/000141_user_status_dormant.up.sql @@ -0,0 +1,2 @@ +ALTER TYPE user_status ADD VALUE IF NOT EXISTS 'dormant'; +COMMENT ON TYPE user_status IS 'Defines the user status: active, dormant, or suspended.'; diff --git a/coderd/database/migrations/000142_user_status_default_dormant.down.sql b/coderd/database/migrations/000142_user_status_default_dormant.down.sql new file mode 100644 index 0000000000000..6789ff246ff07 --- /dev/null +++ b/coderd/database/migrations/000142_user_status_default_dormant.down.sql @@ -0,0 +1 @@ +ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active'::user_status; diff --git a/coderd/database/migrations/000142_user_status_default_dormant.up.sql b/coderd/database/migrations/000142_user_status_default_dormant.up.sql new file mode 100644 index 0000000000000..6789ff246ff07 --- /dev/null +++ b/coderd/database/migrations/000142_user_status_default_dormant.up.sql @@ -0,0 +1 @@ +ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active'::user_status; diff --git a/codersdk/users.go b/codersdk/users.go index 1536635a2106b..deb03b0187817 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -19,6 +19,7 @@ type UserStatus string const ( UserStatusActive UserStatus = "active" + UserStatusDormant UserStatus = "dormant" UserStatusSuspended UserStatus = "suspended" ) From 0091738934943a73ec12e92748686011db24a134 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 21 Jul 2023 13:46:19 +0200 Subject: [PATCH 02/69] generated --- coderd/apidoc/docs.go | 2 ++ coderd/apidoc/swagger.json | 8 ++++++-- docs/api/schemas.md | 1 + site/src/api/typesGenerated.ts | 4 ++-- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 1445a0cd3c9ff..0a01457baecdc 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9753,10 +9753,12 @@ const docTemplate = `{ "type": "string", "enum": [ "active", + "dormant", "suspended" ], "x-enum-varnames": [ "UserStatusActive", + "UserStatusDormant", "UserStatusSuspended" ] }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 4119f580e91ad..9452007348024 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8817,8 +8817,12 @@ }, "codersdk.UserStatus": { "type": "string", - "enum": ["active", "suspended"], - "x-enum-varnames": ["UserStatusActive", "UserStatusSuspended"] + "enum": ["active", "dormant", "suspended"], + "x-enum-varnames": [ + "UserStatusActive", + "UserStatusDormant", + "UserStatusSuspended" + ] }, "codersdk.ValidationError": { "type": "object", diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 075f44fd56a27..1849c8c31528b 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -4757,6 +4757,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | Value | | ----------- | | `active` | +| `dormant` | | `suspended` | ## codersdk.ValidationError diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a6734f669e96a..f18f9cc969f55 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1675,8 +1675,8 @@ export const TemplateVersionWarnings: TemplateVersionWarning[] = [ ] // From codersdk/users.go -export type UserStatus = "active" | "suspended" -export const UserStatuses: UserStatus[] = ["active", "suspended"] +export type UserStatus = "active" | "dormant" | "suspended" +export const UserStatuses: UserStatus[] = ["active", "dormant", "suspended"] // From codersdk/templateversions.go export type ValidationMonotonicOrder = "decreasing" | "increasing" From 6771255b1c4e94350ae8d892a44f1f554c9994fd Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 21 Jul 2023 12:32:13 +0000 Subject: [PATCH 03/69] make gen --- coderd/database/dump.sql | 5 ++++- coderd/database/models.go | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 42baf9ea31c57..b61fb66f76e13 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -113,9 +113,12 @@ CREATE TYPE startup_script_behavior AS ENUM ( CREATE TYPE user_status AS ENUM ( 'active', - 'suspended' + 'suspended', + 'dormant' ); +COMMENT ON TYPE user_status IS 'Defines the user status: active, dormant, or suspended.'; + CREATE TYPE workspace_agent_lifecycle_state AS ENUM ( 'created', 'starting', diff --git a/coderd/database/models.go b/coderd/database/models.go index 80787f57b7642..69a888391ba78 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1032,11 +1032,13 @@ func AllStartupScriptBehaviorValues() []StartupScriptBehavior { } } +// Defines the user status: active, dormant, or suspended. type UserStatus string const ( UserStatusActive UserStatus = "active" UserStatusSuspended UserStatus = "suspended" + UserStatusDormant UserStatus = "dormant" ) func (e *UserStatus) Scan(src interface{}) error { @@ -1077,7 +1079,8 @@ func (ns NullUserStatus) Value() (driver.Value, error) { func (e UserStatus) Valid() bool { switch e { case UserStatusActive, - UserStatusSuspended: + UserStatusSuspended, + UserStatusDormant: return true } return false @@ -1087,6 +1090,7 @@ func AllUserStatusValues() []UserStatus { return []UserStatus{ UserStatusActive, UserStatusSuspended, + UserStatusDormant, } } From 57547aee2a92ca944b94924461ec181dfd0f225f Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 21 Jul 2023 14:52:58 +0200 Subject: [PATCH 04/69] Dormant API --- coderd/coderd.go | 1 + coderd/users.go | 12 ++++++++++++ enterprise/coderd/scim.go | 9 ++++++--- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 5ac7881ccdeb4..f65ab2dc16374 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -698,6 +698,7 @@ func New(options *Options) *API { r.Route("/status", func(r chi.Router) { r.Put("/suspend", api.putSuspendUserAccount()) r.Put("/activate", api.putActivateUserAccount()) + r.Put("/dormant", api.putDormantUserAccount()) }) r.Route("/password", func(r chi.Router) { r.Put("/", api.putUserPassword) diff --git a/coderd/users.go b/coderd/users.go index 7f9f7a7f23a7d..d7000a6684b69 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -629,6 +629,18 @@ func (api *API) putSuspendUserAccount() func(rw http.ResponseWriter, r *http.Req return api.putUserStatus(database.UserStatusSuspended) } +// @Summary Mark user account as dormant +// @ID dormant-user-account +// @Security CoderSessionToken +// @Produce json +// @Tags Users +// @Param user path string true "User ID, name, or me" +// @Success 200 {object} codersdk.User +// @Router /users/{user}/status/dormant [put] +func (api *API) putDormantUserAccount() func(rw http.ResponseWriter, r *http.Request) { + return api.putUserStatus(database.UserStatusDormant) +} + // @Summary Activate user account // @ID activate-user-account // @Security CoderSessionToken diff --git a/enterprise/coderd/scim.go b/enterprise/coderd/scim.go index c46ff8f5dd3d7..9d16ee63d36b4 100644 --- a/enterprise/coderd/scim.go +++ b/enterprise/coderd/scim.go @@ -108,9 +108,10 @@ type SCIMUser struct { Type string `json:"type"` Display string `json:"display"` } `json:"emails"` - Active bool `json:"active"` - Groups []interface{} `json:"groups"` - Meta struct { + Active bool `json:"active"` + Dormant bool `json:"dormant"` + Groups []interface{} `json:"groups"` + Meta struct { ResourceType string `json:"resourceType"` } `json:"meta"` } @@ -245,6 +246,8 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) { var status database.UserStatus if sUser.Active { status = database.UserStatusActive + } else if sUser.Dormant { + status = database.UserStatusDormant } else { status = database.UserStatusSuspended } From f699e6fcd22e4c7add0b7793b329425df8b025ca Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 21 Jul 2023 15:48:31 +0200 Subject: [PATCH 05/69] WIP --- cli/userstatus.go | 1 + .../migrations/000142_user_status_default_dormant.up.sql | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/userstatus.go b/cli/userstatus.go index 6a2ada1a7cd19..f73a136ffbd31 100644 --- a/cli/userstatus.go +++ b/cli/userstatus.go @@ -23,6 +23,7 @@ func (r *RootCmd) createUserStatusCommand(sdkStatus codersdk.UserStatus) *clibas pastVerb = "activated" aliases = []string{"active"} short = "Update a user's status to 'active'. Active users can fully interact with the platform" + // TODO(mtojek): dormant case codersdk.UserStatusSuspended: verb = "suspend" pastVerb = "suspended" diff --git a/coderd/database/migrations/000142_user_status_default_dormant.up.sql b/coderd/database/migrations/000142_user_status_default_dormant.up.sql index 6789ff246ff07..e526e282f65be 100644 --- a/coderd/database/migrations/000142_user_status_default_dormant.up.sql +++ b/coderd/database/migrations/000142_user_status_default_dormant.up.sql @@ -1 +1 @@ -ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active'::user_status; +ALTER TABLE users ALTER COLUMN status SET DEFAULT 'dormant'::user_status; From 3376a0f446a8374abce7bb82aa383ab1bb3b587c Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 21 Jul 2023 13:49:46 +0000 Subject: [PATCH 06/69] make gen --- coderd/apidoc/docs.go | 37 ++++++++++++++++++++++++++++ coderd/apidoc/swagger.json | 33 +++++++++++++++++++++++++ coderd/database/dump.sql | 2 +- docs/api/enterprise.md | 3 +++ docs/api/schemas.md | 2 ++ docs/api/users.md | 50 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 126 insertions(+), 1 deletion(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0a01457baecdc..1632fd10aae71 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4027,6 +4027,40 @@ const docTemplate = `{ } } }, + "/users/{user}/status/dormant": { + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Mark user account as dormant", + "operationId": "dormant-user-account", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.User" + } + } + } + } + }, "/users/{user}/status/suspend": { "put": { "security": [ @@ -6502,6 +6536,9 @@ const docTemplate = `{ "active": { "type": "boolean" }, + "dormant": { + "type": "boolean" + }, "emails": { "type": "array", "items": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9452007348024..d4a88ab07a8f0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3547,6 +3547,36 @@ } } }, + "/users/{user}/status/dormant": { + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Mark user account as dormant", + "operationId": "dormant-user-account", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.User" + } + } + } + } + }, "/users/{user}/status/suspend": { "put": { "security": [ @@ -5770,6 +5800,9 @@ "active": { "type": "boolean" }, + "dormant": { + "type": "boolean" + }, "emails": { "type": "array", "items": { diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index b61fb66f76e13..a5ee9edd759a4 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -595,7 +595,7 @@ CREATE TABLE users ( hashed_password bytea NOT NULL, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, - status user_status DEFAULT 'active'::user_status NOT NULL, + status user_status DEFAULT 'dormant'::user_status NOT NULL, rbac_roles text[] DEFAULT '{}'::text[] NOT NULL, login_type login_type DEFAULT 'password'::login_type NOT NULL, avatar_url text, diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index 4ed43c12ce770..0092585b3071f 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -809,6 +809,7 @@ curl -X POST http://coder-server:8080/api/v2/scim/v2/Users \ ```json { "active": true, + "dormant": true, "emails": [ { "display": "string", @@ -844,6 +845,7 @@ curl -X POST http://coder-server:8080/api/v2/scim/v2/Users \ ```json { "active": true, + "dormant": true, "emails": [ { "display": "string", @@ -919,6 +921,7 @@ curl -X PATCH http://coder-server:8080/api/v2/scim/v2/Users/{id} \ ```json { "active": true, + "dormant": true, "emails": [ { "display": "string", diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 1849c8c31528b..bbc18506eebd7 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -699,6 +699,7 @@ ```json { "active": true, + "dormant": true, "emails": [ { "display": "string", @@ -726,6 +727,7 @@ | Name | Type | Required | Restrictions | Description | | ---------------- | ------------------ | -------- | ------------ | ----------- | | `active` | boolean | false | | | +| `dormant` | boolean | false | | | | `emails` | array of object | false | | | | `» display` | string | false | | | | `» primary` | boolean | false | | | diff --git a/docs/api/users.md b/docs/api/users.md index 1206d42c2e260..680a3ca8ec906 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -1182,6 +1182,56 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/activate \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Mark user account as dormant + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/dormant \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /users/{user}/status/dormant` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | ------ | -------- | -------------------- | +| `user` | path | string | true | User ID, name, or me | + +### Example responses + +> 200 Response + +```json +{ + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "roles": [ + { + "display_name": "string", + "name": "string" + } + ], + "status": "active", + "username": "string" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.User](schemas.md#codersdkuser) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Suspend user account ### Code samples From 4fbc5d92212a7a8c944d77b883b4c0592f49e013 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 21 Jul 2023 16:10:31 +0200 Subject: [PATCH 07/69] userauth --- coderd/userauth.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/coderd/userauth.go b/coderd/userauth.go index 6a1aead1ef8cb..ae3fd1e035747 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -327,6 +327,21 @@ func (api *API) loginRequest(ctx context.Context, rw http.ResponseWriter, req co return user, database.GetAuthorizationUserRolesRow{}, false } + if user.Status == database.UserStatusDormant { + user, err = api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{ + ID: user.ID, + Status: database.UserStatusActive, + UpdatedAt: database.Now(), + }) + if err != nil { + logger.Error(ctx, "unable to update user status to active", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error.", + }) + return user, database.GetAuthorizationUserRolesRow{}, false + } + } + //nolint:gocritic // System needs to fetch user roles in order to login user. roles, err := api.Database.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), user.ID) if err != nil { @@ -340,7 +355,7 @@ func (api *API) loginRequest(ctx context.Context, rw http.ResponseWriter, req co // If the user logged into a suspended account, reject the login request. if roles.Status != database.UserStatusActive { httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{ - Message: "Your account is suspended. Contact an admin to reactivate your account.", + Message: fmt.Sprintf("Your account is %s. Contact an admin to reactivate your account.", roles.Status), }) return user, database.GetAuthorizationUserRolesRow{}, false } From 67c4e966c00370bb78d46da2634a37437187a169 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 21 Jul 2023 16:13:03 +0200 Subject: [PATCH 08/69] fix: lint --- coderd/userauth.go | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/userauth.go b/coderd/userauth.go index ae3fd1e035747..4977f82d0abc8 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -328,6 +328,7 @@ func (api *API) loginRequest(ctx context.Context, rw http.ResponseWriter, req co } if user.Status == database.UserStatusDormant { + //nolint:gocritic // System needs to update status of the user account (dormant -> active). user, err = api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{ ID: user.ID, Status: database.UserStatusActive, From daa025fdde813324383dc3c814ed4b707c70bd27 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 21 Jul 2023 16:26:26 +0200 Subject: [PATCH 09/69] UsersFilter.tsx --- site/src/pages/UsersPage/UsersFilter.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/src/pages/UsersPage/UsersFilter.tsx b/site/src/pages/UsersPage/UsersFilter.tsx index 5399ea2491499..d2a85b23a735e 100644 --- a/site/src/pages/UsersPage/UsersFilter.tsx +++ b/site/src/pages/UsersPage/UsersFilter.tsx @@ -24,7 +24,8 @@ export const useStatusFilterMenu = ({ }: Pick, "value" | "onChange">) => { const statusOptions: StatusOption[] = [ { value: "active", label: "Active", color: "success" }, - { value: "suspended", label: "Suspended", color: "secondary" }, + { value: "dormant", label: "Dormant", color: "secondary" }, + { value: "suspended", label: "Suspended", color: "warning" }, ] return useFilterMenu({ onChange, From b86f6367e3c4954d599af6317673a28fb3261e64 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 21 Jul 2023 17:21:45 +0200 Subject: [PATCH 10/69] Site UI changes --- site/src/api/api.ts | 9 +++ site/src/components/UsersTable/UsersTable.tsx | 3 + .../components/UsersTable/UsersTableBody.tsx | 28 ++++++- site/src/i18n/en/usersPage.json | 1 + site/src/pages/UsersPage/UsersPage.tsx | 36 +++++++++ site/src/pages/UsersPage/UsersPageView.tsx | 3 + site/src/xServices/users/usersXService.ts | 81 +++++++++++++++++++ 7 files changed, 160 insertions(+), 1 deletion(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ba412e39e8764..9f5d222ffe0f8 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -661,6 +661,15 @@ export const activateUser = async ( return response.data } +export const markUserDormant = async ( + userId: TypesGen.User["id"], +): Promise => { + const response = await axios.put( + `/api/v2/users/${userId}/status/dormant`, + ) + return response.data +} + export const suspendUser = async ( userId: TypesGen.User["id"], ): Promise => { diff --git a/site/src/components/UsersTable/UsersTable.tsx b/site/src/components/UsersTable/UsersTable.tsx index 456f9199e2373..fa0399a969088 100644 --- a/site/src/components/UsersTable/UsersTable.tsx +++ b/site/src/components/UsersTable/UsersTable.tsx @@ -26,6 +26,7 @@ export interface UsersTableProps { isLoading?: boolean onSuspendUser: (user: TypesGen.User) => void onActivateUser: (user: TypesGen.User) => void + onMarkUserDormant: (user: TypesGen.User) => void onDeleteUser: (user: TypesGen.User) => void onListWorkspaces: (user: TypesGen.User) => void onViewActivity: (user: TypesGen.User) => void @@ -46,6 +47,7 @@ export const UsersTable: FC> = ({ onListWorkspaces, onViewActivity, onActivateUser, + onMarkUserDormant, onResetUserPassword, onUpdateUserRoles, isUpdatingUserRoles, @@ -83,6 +85,7 @@ export const UsersTable: FC> = ({ canViewActivity={canViewActivity} isUpdatingUserRoles={isUpdatingUserRoles} onActivateUser={onActivateUser} + onMarkUserDormant={onMarkUserDormant} onDeleteUser={onDeleteUser} onListWorkspaces={onListWorkspaces} onViewActivity={onViewActivity} diff --git a/site/src/components/UsersTable/UsersTableBody.tsx b/site/src/components/UsersTable/UsersTableBody.tsx index 8548323ce0976..8af7f3c46063b 100644 --- a/site/src/components/UsersTable/UsersTableBody.tsx +++ b/site/src/components/UsersTable/UsersTableBody.tsx @@ -41,6 +41,7 @@ interface UsersTableBodyProps { onListWorkspaces: (user: TypesGen.User) => void onViewActivity: (user: TypesGen.User) => void onActivateUser: (user: TypesGen.User) => void + onMarkUserDormant: (user: TypesGen.User) => void onResetUserPassword: (user: TypesGen.User) => void onUpdateUserRoles: ( user: TypesGen.User, @@ -60,6 +61,7 @@ export const UsersTableBody: FC< onListWorkspaces, onViewActivity, onActivateUser, + onMarkUserDormant, onResetUserPassword, onUpdateUserRoles, isUpdatingUserRoles, @@ -176,8 +178,25 @@ export const UsersTableBody: FC< onClick: onSuspendUser, disabled: false, }, + { + label: t( + "markUserDormantMenuItem", + ) as React.ReactNode, + onClick: onMarkUserDormant, + disabled: user.id === actorID, + } + ] + : user.status === "suspended" + ? [ + { + label: t( + "activateMenuItem", + ) as React.ReactNode, + onClick: onActivateUser, + disabled: false, + }, ] - : [ + : [ // User account is dormant { label: t( "activateMenuItem", @@ -185,6 +204,13 @@ export const UsersTableBody: FC< onClick: onActivateUser, disabled: false, }, + { + label: t( + "suspendMenuItem", + ) as React.ReactNode, + onClick: onSuspendUser, + disabled: false, + }, ] ).concat( { diff --git a/site/src/i18n/en/usersPage.json b/site/src/i18n/en/usersPage.json index 94b3cec39b330..c8bc3746bce34 100644 --- a/site/src/i18n/en/usersPage.json +++ b/site/src/i18n/en/usersPage.json @@ -5,6 +5,7 @@ "deleteMenuItem": "Delete", "listWorkspacesMenuItem": "View workspaces", "activateMenuItem": "Activate", + "markUserDormantMenuItem": "Mark user as dormant", "resetPasswordMenuItem": "Reset password", "editUserRolesTooltip": "Edit user roles", "fieldSetRolesTooltip": "Available roles", diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 95c0f5670be7d..9c5d5c3968436 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -28,6 +28,9 @@ export const Language = { activateDialogTitle: "Activate user", activateDialogAction: "Activate", activateDialogMessagePrefix: "Do you want to activate the user", + markUserDormantDialogTitle: "Mark user dormant", + markUserDormantDialogAction: "Mark dormant", + markUserDormantDialogMessagePrefix: "Do you want to mark the user account dormant", } const getSelectedUser = (id: string, users?: User[]) => @@ -55,6 +58,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { usernameToDelete, usernameToSuspend, usernameToActivate, + usernameToMarkDormant, userIdToResetPassword, newUserPassword, paginationRef, @@ -137,6 +141,13 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { username: user.username, }) }} + onMarkUserDormant={(user) => { + usersSend({ + type: "MARK_USER_DORMANT", + userId: user.id, + username: user.username, + }) + }} onResetUserPassword={(user) => { usersSend({ type: "RESET_USER_PASSWORD", userId: user.id }) }} @@ -230,6 +241,31 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { } /> + { + usersSend("CONFIRM_USER_DORMANT") + }} + onClose={() => { + usersSend("CANCEL_USER_DORMANT") + }} + description={ + <> + {Language.markUserDormantDialogMessagePrefix} + {usernameToMarkDormant && " "} + {usernameToMarkDormant ?? ""}? + + } + /> + {userIdToResetPassword && ( void onViewActivity: (user: TypesGen.User) => void onActivateUser: (user: TypesGen.User) => void + onMarkUserDormant: (user: TypesGen.User) => void onResetUserPassword: (user: TypesGen.User) => void onUpdateUserRoles: ( user: TypesGen.User, @@ -43,6 +44,7 @@ export const UsersPageView: FC> = ({ onListWorkspaces, onViewActivity, onActivateUser, + onMarkUserDormant, onResetUserPassword, onUpdateUserRoles, isUpdatingUserRoles, @@ -73,6 +75,7 @@ export const UsersPageView: FC> = ({ onListWorkspaces={onListWorkspaces} onViewActivity={onViewActivity} onActivateUser={onActivateUser} + onMarkUserDormant={onMarkUserDormant} onResetUserPassword={onResetUserPassword} onUpdateUserRoles={onUpdateUserRoles} isUpdatingUserRoles={isUpdatingUserRoles} diff --git a/site/src/xServices/users/usersXService.ts b/site/src/xServices/users/usersXService.ts index 37d67b36b1cad..158d662b65855 100644 --- a/site/src/xServices/users/usersXService.ts +++ b/site/src/xServices/users/usersXService.ts @@ -25,6 +25,8 @@ export const Language = { deleteUserError: "Error deleting user.", activateUserSuccess: "Successfully activated the user.", activateUserError: "Error activating user.", + markUserDormantSuccess: "Successfully marked the user account as dormant.", + markUserDormantError: "Error marking user account as dormant.", resetUserPasswordSuccess: "Successfully updated the user password.", resetUserPasswordError: "Error on resetting the user password.", updateUserRolesSuccess: "Successfully updated the user roles.", @@ -48,6 +50,10 @@ export interface UsersContext { userIdToActivate?: TypesGen.User["id"] usernameToActivate?: TypesGen.User["username"] activateUserError?: Error | unknown + // Mark user dormant + userIdToMarkDormant?: TypesGen.User["id"] + usernameToMarkDormant?: TypesGen.User["username"] + markUserDormantError?: Error | unknown // Reset user password userIdToResetPassword?: TypesGen.User["id"] resetUserPasswordError?: Error | unknown @@ -86,6 +92,14 @@ export type UsersEvent = } | { type: "CONFIRM_USER_ACTIVATION" } | { type: "CANCEL_USER_ACTIVATION" } + // Mark as dormant events + | { + type: "MARK_USER_DORMANT" + userId: TypesGen.User["id"] + username: TypesGen.User["username"] + } + | { type: "CONFIRM_USER_DORMANT" } + | { type: "CANCEL_USER_DORMANT" } // Reset password events | { type: "RESET_USER_PASSWORD"; userId: TypesGen.User["id"] } | { type: "CONFIRM_USER_PASSWORD_RESET" } @@ -125,6 +139,9 @@ export const usersMachine = activateUser: { data: TypesGen.User } + markUserDormant: { + data: TypesGen.User + } updateUserPassword: { data: undefined } @@ -187,6 +204,10 @@ export const usersMachine = target: "confirmUserActivation", actions: "assignUserToActivate", }, + MARK_USER_DORMANT: { + target: "confirmUserDormant", + actions: "assignUserToMarkDormant", + }, RESET_USER_PASSWORD: { target: "confirmUserPasswordReset", actions: [ @@ -230,6 +251,16 @@ export const usersMachine = }, }, }, + confirmUserDormant: { + on: { + CONFIRM_USER_DORMANT: { + target: "markingUserDormant", + }, + CANCEL_USER_DORMANT: { + target: "idle", + }, + }, + }, suspendingUser: { entry: "clearSuspendUserError", invoke: { @@ -293,6 +324,28 @@ export const usersMachine = ], }, }, + markingUserDormant: { + entry: "clearMarkUserDormantError", + invoke: { + src: "markUserDormant", + id: "markUserDormant", + onDone: [ + { + target: "gettingUsers", + actions: "displayMarkUserDormantSuccess", + }, + ], + onError: [ + { + target: "idle", + actions: [ + "assignMarkUserDormantError", + "displayMarkUserDormantErrorMessage", + ], + }, + ], + }, + }, confirmUserPasswordReset: { on: { CONFIRM_USER_PASSWORD_RESET: { @@ -382,6 +435,12 @@ export const usersMachine = return API.activateUser(context.userIdToActivate) }, + markUserDormant: (context) => { + if (!context.userIdToMarkDormant) { + throw new Error("userIdToMarkDormant is undefined") + } + return API.markUserDormant(context.userIdToMarkDormant) + }, resetUserPassword: (context) => { if (!context.userIdToResetPassword) { throw new Error("userIdToResetPassword is undefined") @@ -413,6 +472,8 @@ export const usersMachine = usernameToDelete: (_) => undefined, userIdToActivate: (_) => undefined, usernameToActivate: (_) => undefined, + userIdToMarkDormant: (_) => undefined, + usernameToMarkDormant: (_) => undefined, userIdToResetPassword: (_) => undefined, userIdToUpdateRoles: (_) => undefined, }), @@ -438,6 +499,10 @@ export const usersMachine = userIdToActivate: (_, event) => event.userId, usernameToActivate: (_, event) => event.username, }), + assignUserToMarkDormant: assign({ + userIdToMarkDormant: (_, event) => event.userId, + usernameToMarkDormant: (_, event) => event.username, + }), assignUserIdToResetPassword: assign({ userIdToResetPassword: (_, event) => event.userId, }), @@ -457,6 +522,9 @@ export const usersMachine = assignActivateUserError: assign({ activateUserError: (_, event) => event.data, }), + assignMarkUserDormantError: assign({ + markUserDormantError: (_, event) => event.data, + }), assignResetUserPasswordError: assign({ resetUserPasswordError: (_, event) => event.data, }), @@ -477,6 +545,9 @@ export const usersMachine = clearActivateUserError: assign({ activateUserError: (_) => undefined, }), + clearMarkUserDormantError: assign({ + markUserDormantError: (_) => undefined, + }), clearResetUserPasswordError: assign({ resetUserPasswordError: (_) => undefined, }), @@ -513,6 +584,16 @@ export const usersMachine = ) displayError(message) }, + displayMarkUserDormantSuccess: () => { + displaySuccess(Language.markUserDormantSuccess) + }, + displayMarkUserDormantErrorMessage: (context) => { + const message = getErrorMessage( + context.markUserDormantError, + Language.markUserDormantError, + ) + displayError(message) + }, displayResetPasswordSuccess: () => { displaySuccess(Language.resetUserPasswordSuccess) }, From 05cd577d5d25a7c44b5408b0d8bf93f06d28eddc Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 21 Jul 2023 17:23:34 +0200 Subject: [PATCH 11/69] make fmt --- site/src/components/UsersTable/UsersTableBody.tsx | 7 ++++--- site/src/pages/UsersPage/UsersPage.tsx | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/site/src/components/UsersTable/UsersTableBody.tsx b/site/src/components/UsersTable/UsersTableBody.tsx index 8af7f3c46063b..0be0714636cfa 100644 --- a/site/src/components/UsersTable/UsersTableBody.tsx +++ b/site/src/components/UsersTable/UsersTableBody.tsx @@ -184,10 +184,10 @@ export const UsersTableBody: FC< ) as React.ReactNode, onClick: onMarkUserDormant, disabled: user.id === actorID, - } + }, ] : user.status === "suspended" - ? [ + ? [ { label: t( "activateMenuItem", @@ -196,7 +196,8 @@ export const UsersTableBody: FC< disabled: false, }, ] - : [ // User account is dormant + : [ + // User account is dormant { label: t( "activateMenuItem", diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 9c5d5c3968436..463293126c327 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -30,7 +30,8 @@ export const Language = { activateDialogMessagePrefix: "Do you want to activate the user", markUserDormantDialogTitle: "Mark user dormant", markUserDormantDialogAction: "Mark dormant", - markUserDormantDialogMessagePrefix: "Do you want to mark the user account dormant", + markUserDormantDialogMessagePrefix: + "Do you want to mark the user account dormant", } const getSelectedUser = (id: string, users?: User[]) => From 738441b2fc9f1f529e131e8e7c3bafb32fa8148e Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Mon, 24 Jul 2023 16:59:08 +0200 Subject: [PATCH 12/69] CLI changes --- cli/users.go | 1 + cli/userstatus.go | 11 ++++++++--- docs/api/users.md | 1 + docs/cli/users.md | 15 ++++++++------- docs/cli/users_mark-as-dormant.md | 32 +++++++++++++++++++++++++++++++ docs/manifest.json | 5 +++++ 6 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 docs/cli/users_mark-as-dormant.md diff --git a/cli/users.go b/cli/users.go index 76615d05d7f04..63f559bb970ef 100644 --- a/cli/users.go +++ b/cli/users.go @@ -18,6 +18,7 @@ func (r *RootCmd) users() *clibase.Cmd { r.userList(), r.userSingle(), r.createUserStatusCommand(codersdk.UserStatusActive), + r.createUserStatusCommand(codersdk.UserStatusDormant), r.createUserStatusCommand(codersdk.UserStatusSuspended), }, } diff --git a/cli/userstatus.go b/cli/userstatus.go index f73a136ffbd31..cd78eaacc8a1e 100644 --- a/cli/userstatus.go +++ b/cli/userstatus.go @@ -23,7 +23,12 @@ func (r *RootCmd) createUserStatusCommand(sdkStatus codersdk.UserStatus) *clibas pastVerb = "activated" aliases = []string{"active"} short = "Update a user's status to 'active'. Active users can fully interact with the platform" - // TODO(mtojek): dormant + case codersdk.UserStatusDormant: + verb = "mark as dormant" + pastVerb = "marked as dormant" + aliases = []string{"dormant"} + // FIXME(mtojek): short = "Update a user's status to 'dormant'. Dormant users are not counted in the license plan" + short = "Update a user's status to 'dormant'." case codersdk.UserStatusSuspended: verb = "suspend" pastVerb = "suspended" @@ -37,12 +42,12 @@ func (r *RootCmd) createUserStatusCommand(sdkStatus codersdk.UserStatus) *clibas var columns []string cmd := &clibase.Cmd{ - Use: fmt.Sprintf("%s ", verb), + Use: fmt.Sprintf("%s ", strings.ReplaceAll(verb, " ", "-")), Short: short, Aliases: aliases, Long: formatExamples( example{ - Command: fmt.Sprintf("coder users %s example_user", verb), + Command: fmt.Sprintf("coder users %s example_user", strings.ReplaceAll(verb, " ", "-")), }, ), Middleware: clibase.Chain( diff --git a/docs/api/users.md b/docs/api/users.md index 643ee720a920d..46e280d5b0a3a 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -1220,6 +1220,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/dormant \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "password", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { diff --git a/docs/cli/users.md b/docs/cli/users.md index ade49b04a866b..f847894b00bb8 100644 --- a/docs/cli/users.md +++ b/docs/cli/users.md @@ -16,10 +16,11 @@ coder users [subcommand] ## Subcommands -| Name | Purpose | -| -------------------------------------------- | ------------------------------------------------------------------------------------- | -| [activate](./users_activate.md) | Update a user's status to 'active'. Active users can fully interact with the platform | -| [create](./users_create.md) | | -| [list](./users_list.md) | | -| [show](./users_show.md) | Show a single user. Use 'me' to indicate the currently authenticated user. | -| [suspend](./users_suspend.md) | Update a user's status to 'suspended'. A suspended user cannot log into the platform | +| Name | Purpose | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| [activate](./users_activate.md) | Update a user's status to 'active'. Active users can fully interact with the platform | +| [create](./users_create.md) | | +| [list](./users_list.md) | | +| [mark-as-dormant](./users_mark-as-dormant.md) | Update a user's status to 'dormant'. | +| [show](./users_show.md) | Show a single user. Use 'me' to indicate the currently authenticated user. | +| [suspend](./users_suspend.md) | Update a user's status to 'suspended'. A suspended user cannot log into the platform | diff --git a/docs/cli/users_mark-as-dormant.md b/docs/cli/users_mark-as-dormant.md new file mode 100644 index 0000000000000..f3c8305013f7e --- /dev/null +++ b/docs/cli/users_mark-as-dormant.md @@ -0,0 +1,32 @@ + + +# users mark-as-dormant + +Update a user's status to 'dormant'. + +Aliases: + +- dormant + +## Usage + +```console +coder users mark-as-dormant [flags] +``` + +## Description + +```console + $ coder users mark-as-dormant example_user +``` + +## Options + +### -c, --column + +| | | +| ------- | --------------------------------------------- | +| Type | string-array | +| Default | username,email,created_at,status | + +Specify a column to filter in the table. diff --git a/docs/manifest.json b/docs/manifest.json index 423bb33353a70..d3c031a5d3d32 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -846,6 +846,11 @@ "title": "users list", "path": "cli/users_list.md" }, + { + "title": "users mark-as-dormant", + "description": "Update a user's status to 'dormant'.", + "path": "cli/users_mark-as-dormant.md" + }, { "title": "users show", "description": "Show a single user. Use 'me' to indicate the currently authenticated user.", From 3ff2962a0282b13f2e39a23fd90e7298224d0cd4 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 25 Jul 2023 11:31:02 +0200 Subject: [PATCH 13/69] CLI fix --- cli/userstatus.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cli/userstatus.go b/cli/userstatus.go index cd78eaacc8a1e..2fa4f453e57ce 100644 --- a/cli/userstatus.go +++ b/cli/userstatus.go @@ -81,8 +81,14 @@ func (r *RootCmd) createUserStatusCommand(sdkStatus codersdk.UserStatus) *clibas } // Prompt to confirm the action + var question string + if sdkStatus == codersdk.UserStatusDormant { + question = "Are you sure you want to mark this user as dormant?" + } else { + question = fmt.Sprintf("Are you sure you want to %s this user?", verb) + } _, err = cliui.Prompt(inv, cliui.PromptOptions{ - Text: fmt.Sprintf("Are you sure you want to %s this user?", verb), + Text: question, IsConfirm: true, Default: cliui.ConfirmYes, }) From 08b9cbba451aa0de14ff58514d4f0299b2443982 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 25 Jul 2023 12:15:21 +0200 Subject: [PATCH 14/69] UI fixes --- coderd/users.go | 8 ++++++++ site/src/i18n/en/usersPage.json | 2 +- site/src/pages/UsersPage/UsersPage.tsx | 15 +++++++-------- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/coderd/users.go b/coderd/users.go index 677cc61fb509e..10fb58e8dffc6 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -12,6 +12,8 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "cdr.dev/slog" + "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/db2sdk" @@ -713,6 +715,12 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW return } + err = api.Pubsub.Publish("licenses", []byte("add")) // FIXME PubsubEventLicenses + if err != nil { + api.Logger.Error(context.Background(), "failed to publish license add", slog.Error(err)) + // don't fail the HTTP request, since we did write it successfully to the database + } + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(suspendedUser, organizations)) } } diff --git a/site/src/i18n/en/usersPage.json b/site/src/i18n/en/usersPage.json index c8bc3746bce34..c766199c84558 100644 --- a/site/src/i18n/en/usersPage.json +++ b/site/src/i18n/en/usersPage.json @@ -5,7 +5,7 @@ "deleteMenuItem": "Delete", "listWorkspacesMenuItem": "View workspaces", "activateMenuItem": "Activate", - "markUserDormantMenuItem": "Mark user as dormant", + "markUserDormantMenuItem": "Mark as dormant", "resetPasswordMenuItem": "Reset password", "editUserRolesTooltip": "Edit user roles", "fieldSetRolesTooltip": "Available roles", diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index e137cf24d3a90..5d8495dc95c76 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -29,10 +29,10 @@ export const Language = { activateDialogTitle: "Activate user", activateDialogAction: "Activate", activateDialogMessagePrefix: "Do you want to activate the user", - markUserDormantDialogTitle: "Mark user dormant", - markUserDormantDialogAction: "Mark dormant", + markUserDormantDialogTitle: "Mark user as dormant", + markUserDormantDialogAction: "Mark as dormant", markUserDormantDialogMessagePrefix: - "Do you want to mark the user account dormant", + "Do you want to mark the user account as dormant", } const getSelectedUser = (id: string, users?: User[]) => @@ -262,8 +262,8 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { usersState.matches("markingUserDormant") } confirmLoading={usersState.matches("markingUserDormant")} - title={Language.markUserDormantDialogTitle} - confirmText={Language.markUserDormantDialogAction} + title="Mark user as dormant" + confirmText="Mark as dormant" onConfirm={() => { usersSend("CONFIRM_USER_DORMANT") }} @@ -272,9 +272,8 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { }} description={ <> - {Language.markUserDormantDialogMessagePrefix} - {usernameToMarkDormant && " "} - {usernameToMarkDormant ?? ""}? + Do you want to mark the user {" "} + {usernameToMarkDormant ?? ""} as dormant? } /> From a3b3f65c64cef51cff7a778fad6bad9e741a586d Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 25 Jul 2023 12:23:14 +0200 Subject: [PATCH 15/69] FIXME pubsub --- coderd/licenses.go | 5 +++++ coderd/users.go | 2 +- enterprise/coderd/coderd.go | 2 +- enterprise/coderd/coderd_test.go | 2 +- enterprise/coderd/licenses.go | 8 ++------ 5 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 coderd/licenses.go diff --git a/coderd/licenses.go b/coderd/licenses.go new file mode 100644 index 0000000000000..822124c5d5af0 --- /dev/null +++ b/coderd/licenses.go @@ -0,0 +1,5 @@ +package coderd + +const ( + PubsubEventLicenses = "licenses" +) diff --git a/coderd/users.go b/coderd/users.go index 10fb58e8dffc6..84a1637327f6b 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -715,7 +715,7 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW return } - err = api.Pubsub.Publish("licenses", []byte("add")) // FIXME PubsubEventLicenses + err = api.Pubsub.Publish(PubsubEventLicenses, []byte("add")) if err != nil { api.Logger.Error(context.Background(), "failed to publish license add", slog.Error(err)) // don't fail the HTTP request, since we did write it successfully to the database diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 1d22e668c6e84..18cad48a0cfd5 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -605,7 +605,7 @@ func (api *API) runEntitlementsLoop(ctx context.Context) { // pass } if !subscribed { - cancel, err := api.Pubsub.Subscribe(PubsubEventLicenses, func(_ context.Context, _ []byte) { + cancel, err := api.Pubsub.Subscribe(coderd.PubsubEventLicenses, func(_ context.Context, _ []byte) { // don't block. If the channel is full, drop the event, as there is a resync // scheduled already. select { diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 2271c79064b4f..05c948c34fefe 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" + "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/rbac" @@ -20,7 +21,6 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/audit" - "github.com/coder/coder/enterprise/coderd" "github.com/coder/coder/enterprise/coderd/coderdenttest" "github.com/coder/coder/enterprise/coderd/license" "github.com/coder/coder/testutil" diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index 24085ee9a7bea..d38cde4b538e2 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -28,10 +28,6 @@ import ( "github.com/coder/coder/enterprise/coderd/license" ) -const ( - PubsubEventLicenses = "licenses" -) - // key20220812 is the Coder license public key with id 2022-08-12 used to validate licenses signed // by our signing infrastructure // @@ -141,7 +137,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) { }) return } - err = api.Pubsub.Publish(PubsubEventLicenses, []byte("add")) + err = api.Pubsub.Publish(coderd.PubsubEventLicenses, []byte("add")) if err != nil { api.Logger.Error(context.Background(), "failed to publish license add", slog.Error(err)) // don't fail the HTTP request, since we did write it successfully to the database @@ -256,7 +252,7 @@ func (api *API) deleteLicense(rw http.ResponseWriter, r *http.Request) { }) return } - err = api.Pubsub.Publish(PubsubEventLicenses, []byte("delete")) + err = api.Pubsub.Publish(coderd.PubsubEventLicenses, []byte("delete")) if err != nil { api.Logger.Error(context.Background(), "failed to publish license delete", slog.Error(err)) // don't fail the HTTP request, since we did write it successfully to the database From e63f7a8ebed2763bc870e21376eac3da664a2b9e Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 25 Jul 2023 16:22:46 +0200 Subject: [PATCH 16/69] Add learn more link2 --- site/src/components/Filter/filter.tsx | 26 ++++++++++++++++++++++++ site/src/pages/UsersPage/UsersFilter.tsx | 2 ++ site/src/pages/UsersPage/UsersPage.tsx | 2 +- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/site/src/components/Filter/filter.tsx b/site/src/components/Filter/filter.tsx index 89e4532105983..fa20e6f9b9aa8 100644 --- a/site/src/components/Filter/filter.tsx +++ b/site/src/components/Filter/filter.tsx @@ -137,12 +137,16 @@ export const Filter = ({ skeleton, options, learnMoreLink, + learnMoreLabel2, + learnMoreLink2, presets, }: { filter: ReturnType skeleton: ReactNode isLoading: boolean learnMoreLink: string + learnMoreLabel2?: string + learnMoreLink2?: string error?: unknown options?: ReactNode presets: PresetFilter[] @@ -178,6 +182,8 @@ export const Filter = ({ onSelect={(query) => filter.update(query)} presets={presets} learnMoreLink={learnMoreLink} + learnMoreLabel2={learnMoreLabel2} + learnMoreLink2={learnMoreLink2} /> void }) => { const [isOpen, setIsOpen] = useState(false) @@ -317,6 +327,22 @@ const PresetMenu = ({ View advanced filtering + {learnMoreLink2 && learnMoreLabel2 && ( + <> + { + setIsOpen(false) + }} + > + + {learnMoreLabel2} + + + )} ) diff --git a/site/src/pages/UsersPage/UsersFilter.tsx b/site/src/pages/UsersPage/UsersFilter.tsx index d2a85b23a735e..e89e765cae31b 100644 --- a/site/src/pages/UsersPage/UsersFilter.tsx +++ b/site/src/pages/UsersPage/UsersFilter.tsx @@ -59,6 +59,8 @@ export const UsersFilter = ({ = () => { }} description={ <> - Do you want to mark the user {" "} + Do you want to mark the user{" "} {usernameToMarkDormant ?? ""} as dormant? } From b7a870e3bbc201c6a1776fa5385e8f5171691de9 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 25 Jul 2023 18:49:51 +0200 Subject: [PATCH 17/69] Fix: migrations --- ...tatus_dormant.down.sql => 000142_user_status_dormant.down.sql} | 0 ...er_status_dormant.up.sql => 000142_user_status_dormant.up.sql} | 0 ...rmant.down.sql => 000143_user_status_default_dormant.down.sql} | 0 ...t_dormant.up.sql => 000143_user_status_default_dormant.up.sql} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000141_user_status_dormant.down.sql => 000142_user_status_dormant.down.sql} (100%) rename coderd/database/migrations/{000141_user_status_dormant.up.sql => 000142_user_status_dormant.up.sql} (100%) rename coderd/database/migrations/{000142_user_status_default_dormant.down.sql => 000143_user_status_default_dormant.down.sql} (100%) rename coderd/database/migrations/{000142_user_status_default_dormant.up.sql => 000143_user_status_default_dormant.up.sql} (100%) diff --git a/coderd/database/migrations/000141_user_status_dormant.down.sql b/coderd/database/migrations/000142_user_status_dormant.down.sql similarity index 100% rename from coderd/database/migrations/000141_user_status_dormant.down.sql rename to coderd/database/migrations/000142_user_status_dormant.down.sql diff --git a/coderd/database/migrations/000141_user_status_dormant.up.sql b/coderd/database/migrations/000142_user_status_dormant.up.sql similarity index 100% rename from coderd/database/migrations/000141_user_status_dormant.up.sql rename to coderd/database/migrations/000142_user_status_dormant.up.sql diff --git a/coderd/database/migrations/000142_user_status_default_dormant.down.sql b/coderd/database/migrations/000143_user_status_default_dormant.down.sql similarity index 100% rename from coderd/database/migrations/000142_user_status_default_dormant.down.sql rename to coderd/database/migrations/000143_user_status_default_dormant.down.sql diff --git a/coderd/database/migrations/000142_user_status_default_dormant.up.sql b/coderd/database/migrations/000143_user_status_default_dormant.up.sql similarity index 100% rename from coderd/database/migrations/000142_user_status_default_dormant.up.sql rename to coderd/database/migrations/000143_user_status_default_dormant.up.sql From d90ea27754d4b93cb8e7a369a62937e966cc342c Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 26 Jul 2023 11:37:57 +0200 Subject: [PATCH 18/69] dump.sql --- coderd/database/dump.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 36f79a30c4c66..aae04d81fb79c 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -554,7 +554,7 @@ CREATE TABLE users ( hashed_password bytea NOT NULL, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, - status user_status DEFAULT 'active'::user_status NOT NULL, + status user_status DEFAULT 'dormant'::user_status NOT NULL, rbac_roles text[] DEFAULT '{}'::text[] NOT NULL, login_type login_type DEFAULT 'password'::login_type NOT NULL, avatar_url text, From 83b8f63935d830914a2d7fc9f5532e25676a10be Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 26 Jul 2023 11:39:06 +0200 Subject: [PATCH 19/69] FIXME --- cli/userstatus.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cli/userstatus.go b/cli/userstatus.go index 2fa4f453e57ce..0ff736dff1121 100644 --- a/cli/userstatus.go +++ b/cli/userstatus.go @@ -27,8 +27,7 @@ func (r *RootCmd) createUserStatusCommand(sdkStatus codersdk.UserStatus) *clibas verb = "mark as dormant" pastVerb = "marked as dormant" aliases = []string{"dormant"} - // FIXME(mtojek): short = "Update a user's status to 'dormant'. Dormant users are not counted in the license plan" - short = "Update a user's status to 'dormant'." + short = "Update a user's status to 'dormant'. Dormant users are not counted in the license plan" case codersdk.UserStatusSuspended: verb = "suspend" pastVerb = "suspended" From 1553871fce590003bd662f6bcc5a9cc49794ef6e Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 26 Jul 2023 11:48:05 +0200 Subject: [PATCH 20/69] CLI tests --- cli/testdata/coder_users_--help.golden | 18 ++++++----- .../coder_users_mark-as-dormant_--help.golden | 15 ++++++++++ cli/userstatus_test.go | 30 +++++++++++++++++++ codersdk/users.go | 2 ++ 4 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 cli/testdata/coder_users_mark-as-dormant_--help.golden diff --git a/cli/testdata/coder_users_--help.golden b/cli/testdata/coder_users_--help.golden index d4774b4406022..57ebe305bd195 100644 --- a/cli/testdata/coder_users_--help.golden +++ b/cli/testdata/coder_users_--help.golden @@ -5,14 +5,16 @@ Manage users Aliases: user Subcommands - activate Update a user's status to 'active'. Active users can fully - interact with the platform - create - list - show Show a single user. Use 'me' to indicate the currently - authenticated user. - suspend Update a user's status to 'suspended'. A suspended user cannot - log into the platform + activate Update a user's status to 'active'. Active users can + fully interact with the platform + create + list + mark-as-dormant Update a user's status to 'dormant'. Dormant users are + not counted in the license plan + show Show a single user. Use 'me' to indicate the currently + authenticated user. + suspend Update a user's status to 'suspended'. A suspended user + cannot log into the platform --- Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_users_mark-as-dormant_--help.golden b/cli/testdata/coder_users_mark-as-dormant_--help.golden new file mode 100644 index 0000000000000..de374222d2d7e --- /dev/null +++ b/cli/testdata/coder_users_mark-as-dormant_--help.golden @@ -0,0 +1,15 @@ +Usage: coder users mark-as-dormant [flags] + +Update a user's status to 'dormant'. Dormant users are not counted in the +license plan + +Aliases: dormant + + $ coder users mark-as-dormant example_user  + +Options + -c, --column string-array (default: username,email,created_at,status) + Specify a column to filter in the table. + +--- +Run `coder --help` for a list of global options. diff --git a/cli/userstatus_test.go b/cli/userstatus_test.go index f5aa3c8e161ad..d4fa9f536bf7e 100644 --- a/cli/userstatus_test.go +++ b/cli/userstatus_test.go @@ -61,4 +61,34 @@ func TestUserStatus(t *testing.T) { require.NoError(t, err, "fetch active user") require.Equal(t, codersdk.UserStatusActive, otherUser.Status, "active user") }) + + t.Run("StatusDormant", func(t *testing.T) { + t.Parallel() + require.Equal(t, codersdk.UserStatusActive, otherUser.Status, "start as active") + + inv, root := clitest.New(t, "users", "dormant", otherUser.Username) + clitest.SetupConfig(t, client, root) + // Yes to the prompt + inv.Stdin = bytes.NewReader([]byte("yes\n")) + err := inv.Run() + require.NoError(t, err, "mark as dormant") + + // Check the user status + otherUser, err = client.User(context.Background(), otherUser.Username) + require.NoError(t, err, "fetch dormant user") + require.Equal(t, codersdk.UserStatusDormant, otherUser.Status, "marked as dormant") + + // Set back to active. Try using a uuid as well + inv, root = clitest.New(t, "users", "activate", otherUser.ID.String()) + clitest.SetupConfig(t, client, root) + // Yes to the prompt + inv.Stdin = bytes.NewReader([]byte("yes\n")) + err = inv.Run() + require.NoError(t, err, "suspend user") + + // Check the user status + otherUser, err = client.User(context.Background(), otherUser.ID.String()) + require.NoError(t, err, "fetch active user") + require.Equal(t, codersdk.UserStatusActive, otherUser.Status, "active user") + }) } diff --git a/codersdk/users.go b/codersdk/users.go index d6e15c3b5cf77..670dc6e679de2 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -259,6 +259,8 @@ func (c *Client) UpdateUserStatus(ctx context.Context, user string, status UserS switch status { case UserStatusActive: path += "activate" + case UserStatusDormant: + path += "dormant" case UserStatusSuspended: path += "suspend" default: From 6a959d6c0fc87a361ff53dc2179f2340e7ef981e Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 26 Jul 2023 11:53:51 +0200 Subject: [PATCH 21/69] oss --- enterprise/coderd/coderd_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 05c948c34fefe..880ffaf68f19e 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -8,7 +8,7 @@ import ( "github.com/google/uuid" - "github.com/coder/coder/coderd" + osscoderd "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/rbac" @@ -122,7 +122,7 @@ func TestEntitlements(t *testing.T) { }), }) require.NoError(t, err) - err = api.Pubsub.Publish(coderd.PubsubEventLicenses, []byte{}) + err = api.Pubsub.Publish(osscoderd.PubsubEventLicenses, []byte{}) require.NoError(t, err) require.Eventually(t, func() bool { entitlements, err := client.Entitlements(context.Background()) From f7d30613ff0bf1cb984bf4a496b6d73e87109a67 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 26 Jul 2023 11:57:41 +0200 Subject: [PATCH 22/69] next fixes --- .../000142_user_status_dormant.down.sql | 2 -- docs/cli/users.md | 16 ++++++++-------- docs/cli/users_mark-as-dormant.md | 2 +- docs/manifest.json | 2 +- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/coderd/database/migrations/000142_user_status_dormant.down.sql b/coderd/database/migrations/000142_user_status_dormant.down.sql index 2a471829b0075..c55c8a84f3647 100644 --- a/coderd/database/migrations/000142_user_status_dormant.down.sql +++ b/coderd/database/migrations/000142_user_status_dormant.down.sql @@ -1,3 +1 @@ -- It's not possible to drop enum values from enum types, so the UP has "IF NOT EXISTS" - -ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active'::user_status; diff --git a/docs/cli/users.md b/docs/cli/users.md index f847894b00bb8..e075512deda08 100644 --- a/docs/cli/users.md +++ b/docs/cli/users.md @@ -16,11 +16,11 @@ coder users [subcommand] ## Subcommands -| Name | Purpose | -| ---------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| [activate](./users_activate.md) | Update a user's status to 'active'. Active users can fully interact with the platform | -| [create](./users_create.md) | | -| [list](./users_list.md) | | -| [mark-as-dormant](./users_mark-as-dormant.md) | Update a user's status to 'dormant'. | -| [show](./users_show.md) | Show a single user. Use 'me' to indicate the currently authenticated user. | -| [suspend](./users_suspend.md) | Update a user's status to 'suspended'. A suspended user cannot log into the platform | +| Name | Purpose | +| ---------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| [activate](./users_activate.md) | Update a user's status to 'active'. Active users can fully interact with the platform | +| [create](./users_create.md) | | +| [list](./users_list.md) | | +| [mark-as-dormant](./users_mark-as-dormant.md) | Update a user's status to 'dormant'. Dormant users are not counted in the license plan | +| [show](./users_show.md) | Show a single user. Use 'me' to indicate the currently authenticated user. | +| [suspend](./users_suspend.md) | Update a user's status to 'suspended'. A suspended user cannot log into the platform | diff --git a/docs/cli/users_mark-as-dormant.md b/docs/cli/users_mark-as-dormant.md index f3c8305013f7e..e2c3cd586dd6b 100644 --- a/docs/cli/users_mark-as-dormant.md +++ b/docs/cli/users_mark-as-dormant.md @@ -2,7 +2,7 @@ # users mark-as-dormant -Update a user's status to 'dormant'. +Update a user's status to 'dormant'. Dormant users are not counted in the license plan Aliases: diff --git a/docs/manifest.json b/docs/manifest.json index 943342d8a97d3..64128dfb6d467 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -858,7 +858,7 @@ }, { "title": "users mark-as-dormant", - "description": "Update a user's status to 'dormant'.", + "description": "Update a user's status to 'dormant'. Dormant users are not counted in the license plan", "path": "cli/users_mark-as-dormant.md" }, { From 4a530aec79567e63f18ed0f1c4c7efa6a3a7924f Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 26 Jul 2023 12:47:47 +0200 Subject: [PATCH 23/69] Unit test: dormant to active after login --- coderd/database/dbfake/dbfake.go | 2 +- coderd/users_test.go | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 21b026bf2c782..7b62a2df8a011 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -3840,7 +3840,7 @@ func (q *FakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam CreatedAt: arg.CreatedAt, UpdatedAt: arg.UpdatedAt, Username: arg.Username, - Status: database.UserStatusActive, + Status: database.UserStatusDormant, RBACRoles: arg.RBACRoles, LoginType: arg.LoginType, } diff --git a/coderd/users_test.go b/coderd/users_test.go index 504b29a52fcee..9045badd4a975 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1510,6 +1510,44 @@ func TestWorkspacesByUser(t *testing.T) { }) } +func TestDormantUser(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Create a new user + newUser, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "test@coder.com", + Username: "someone", + Password: "MySecurePassword!", + OrganizationID: user.OrganizationID, + }) + require.NoError(t, err) + + // User should be dormant as they haven't logged in yet + users, err := client.Users(ctx, codersdk.UsersRequest{Search: newUser.Username}) + require.NoError(t, err) + require.Len(t, users.Users, 1) + require.Equal(t, codersdk.UserStatusDormant, users.Users[0].Status) + + // User logs in now + _, err = client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: newUser.Email, + Password: "MySecurePassword!", + }) + require.NoError(t, err) + + // User status should be active now + users, err = client.Users(ctx, codersdk.UsersRequest{Search: newUser.Username}) + require.NoError(t, err) + require.Len(t, users.Users, 1) + require.Equal(t, codersdk.UserStatusActive, users.Users[0].Status) +} + // TestSuspendedPagination is when the after_id is a suspended record. // The database query should still return the correct page, as the after_id // is in a subquery that finds the record regardless of its status. From c359d9f1991e5fdf252c535b4b20122f7cd0410a Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 26 Jul 2023 13:04:35 +0200 Subject: [PATCH 24/69] More tests --- coderd/users.go | 6 ++--- coderd/users_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/coderd/users.go b/coderd/users.go index 84a1637327f6b..8240c60cf7425 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -692,7 +692,7 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW } } - suspendedUser, err := api.Database.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ + updatedUser, err := api.Database.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ ID: user.ID, Status: status, UpdatedAt: database.Now(), @@ -704,7 +704,7 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW }) return } - aReq.New = suspendedUser + aReq.New = updatedUser organizations, err := userOrganizationIDs(ctx, api, user) if err != nil { @@ -721,7 +721,7 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW // don't fail the HTTP request, since we did write it successfully to the database } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(suspendedUser, organizations)) + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(updatedUser, organizations)) } } diff --git a/coderd/users_test.go b/coderd/users_test.go index 9045badd4a975..a9aa0dd215511 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1048,6 +1048,64 @@ func TestPutUserSuspend(t *testing.T) { }) } +func TestPutUserDormant(t *testing.T) { + t.Parallel() + + t.Run("MarkOwnerAsDormant", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + me := coderdtest.CreateFirstUser(t, client) + _, user := coderdtest.CreateAnotherUser(t, client, me.OrganizationID, rbac.RoleOwner()) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.UpdateUserStatus(ctx, user.Username, codersdk.UserStatusDormant) + require.Error(t, err, "cannot mark owners as dormant") + }) + + t.Run("MarkAnotherUserDormant", func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) + numLogs := len(auditor.AuditLogs()) + + me := coderdtest.CreateFirstUser(t, client) + numLogs++ // add an audit log for user create + numLogs++ // add an audit log for login + + _, user := coderdtest.CreateAnotherUser(t, client, me.OrganizationID) + numLogs++ // add an audit log for user create + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + user, err := client.UpdateUserStatus(ctx, user.Username, codersdk.UserStatusDormant) + require.NoError(t, err) + require.Equal(t, user.Status, codersdk.UserStatusDormant) + numLogs++ // add an audit log for user update + + require.Len(t, auditor.AuditLogs(), numLogs) + require.Equal(t, database.AuditActionWrite, auditor.AuditLogs()[numLogs-1].Action) + }) + + t.Run("MarkOwnUserDormant", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + client.User(ctx, codersdk.Me) + _, err := client.UpdateUserStatus(ctx, codersdk.Me, codersdk.UserStatusDormant) + + require.ErrorContains(t, err, "cannot mark own user as dormant") + }) +} + +// FIXME Activate dormant account + func TestGetUser(t *testing.T) { t.Parallel() From a771d3577daf121fcf35a9fc0b9612cd962449b8 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 27 Jul 2023 11:38:21 +0200 Subject: [PATCH 25/69] More tests fixed --- coderd/users.go | 19 ++++++++++++++++++- coderd/users_test.go | 44 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/coderd/users.go b/coderd/users.go index 25c5260a5ecb2..5b5b3ef6d8ea8 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -671,7 +671,24 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW defer commitAudit() aReq.Old = user - if status == database.UserStatusSuspended { + if status == database.UserStatusDormant { + // There are some manual protections when marking a user as dormant to + // prevent certain situations. + switch { + case user.ID == apiKey.UserID: + // User can't mark themselves as dormant, as they are active now. + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "You cannot mark own user as dormant.", + }) + return + case slice.Contains(user.RBACRoles, rbac.RoleOwner()): + // You can't mark an owner account as dormant + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("You cannot mark a user as dormant with the %q role. You must remove the role first.", rbac.RoleOwner()), + }) + return + } + } else if status == database.UserStatusSuspended { // There are some manual protections when suspending a user to // prevent certain situations. switch { diff --git a/coderd/users_test.go b/coderd/users_test.go index 12620f9d1bd21..9aa163363eebc 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1097,14 +1097,40 @@ func TestPutUserDormant(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - client.User(ctx, codersdk.Me) _, err := client.UpdateUserStatus(ctx, codersdk.Me, codersdk.UserStatusDormant) require.ErrorContains(t, err, "cannot mark own user as dormant") }) } -// FIXME Activate dormant account +func TestActivateDormantUser(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + + // Create users + me := coderdtest.CreateFirstUser(t, client) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + anotherUser, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "coder@coder.com", + Username: "coder", + Password: "SomeStrongPassword!", + OrganizationID: me.OrganizationID, + }) + require.NoError(t, err) + + // Ensure that new user has dormant account + require.Equal(t, codersdk.UserStatusDormant, anotherUser.Status) + + // Activate user account + _, err = client.UpdateUserStatus(ctx, anotherUser.Username, codersdk.UserStatusActive) + require.NoError(t, err) + + // Verify if the account is active now + anotherUser, err = client.User(ctx, anotherUser.Username) + require.NoError(t, err) + require.Equal(t, codersdk.UserStatusActive, anotherUser.Status) +} func TestGetUser(t *testing.T) { t.Parallel() @@ -1426,17 +1452,21 @@ func TestGetUsers(t *testing.T) { }) require.NoError(t, err) - bruno, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "bruno@email.com", - Username: "bruno", + _, err = client.UpdateUserStatus(ctx, alice.Username, codersdk.UserStatusSuspended) + require.NoError(t, err) + + // Tom will be active + tom, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "tom@email.com", + Username: "tom", Password: "MySecurePassword!", OrganizationID: first.OrganizationID, }) require.NoError(t, err) - active = append(active, bruno) - _, err = client.UpdateUserStatus(ctx, alice.Username, codersdk.UserStatusSuspended) + tom, err = client.UpdateUserStatus(ctx, tom.Username, codersdk.UserStatusActive) require.NoError(t, err) + active = append(active, tom) res, err := client.Users(ctx, codersdk.UsersRequest{ Status: codersdk.UserStatusActive, From 5694c08f180ff8fee6f0bcda31fb6591e4a17600 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 27 Jul 2023 11:55:45 +0200 Subject: [PATCH 26/69] Fix migrations --- ...tatus_dormant.down.sql => 000143_user_status_dormant.down.sql} | 0 ...er_status_dormant.up.sql => 000143_user_status_dormant.up.sql} | 0 ...rmant.down.sql => 000144_user_status_default_dormant.down.sql} | 0 ...t_dormant.up.sql => 000144_user_status_default_dormant.up.sql} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000142_user_status_dormant.down.sql => 000143_user_status_dormant.down.sql} (100%) rename coderd/database/migrations/{000142_user_status_dormant.up.sql => 000143_user_status_dormant.up.sql} (100%) rename coderd/database/migrations/{000143_user_status_default_dormant.down.sql => 000144_user_status_default_dormant.down.sql} (100%) rename coderd/database/migrations/{000143_user_status_default_dormant.up.sql => 000144_user_status_default_dormant.up.sql} (100%) diff --git a/coderd/database/migrations/000142_user_status_dormant.down.sql b/coderd/database/migrations/000143_user_status_dormant.down.sql similarity index 100% rename from coderd/database/migrations/000142_user_status_dormant.down.sql rename to coderd/database/migrations/000143_user_status_dormant.down.sql diff --git a/coderd/database/migrations/000142_user_status_dormant.up.sql b/coderd/database/migrations/000143_user_status_dormant.up.sql similarity index 100% rename from coderd/database/migrations/000142_user_status_dormant.up.sql rename to coderd/database/migrations/000143_user_status_dormant.up.sql diff --git a/coderd/database/migrations/000143_user_status_default_dormant.down.sql b/coderd/database/migrations/000144_user_status_default_dormant.down.sql similarity index 100% rename from coderd/database/migrations/000143_user_status_default_dormant.down.sql rename to coderd/database/migrations/000144_user_status_default_dormant.down.sql diff --git a/coderd/database/migrations/000143_user_status_default_dormant.up.sql b/coderd/database/migrations/000144_user_status_default_dormant.up.sql similarity index 100% rename from coderd/database/migrations/000143_user_status_default_dormant.up.sql rename to coderd/database/migrations/000144_user_status_default_dormant.up.sql From 157b6bfb7495362a96c3983bb93e285920810870 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 27 Jul 2023 12:08:58 +0200 Subject: [PATCH 27/69] Fix: OIDC callback --- coderd/userauth.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/coderd/userauth.go b/coderd/userauth.go index 6b35b40f4398c..9072b817df9e9 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1305,6 +1305,20 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C } } + // Activate dormant user on sigin + if user.Status == database.UserStatusDormant { + //nolint:gocritic // System needs to update status of the user account (dormant -> active). + user, err = tx.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{ + ID: user.ID, + Status: database.UserStatusActive, + UpdatedAt: database.Now(), + }) + if err != nil { + logger.Error(ctx, "unable to update user status to active", slog.Error(err)) + return xerrors.Errorf("update user status: %w", err) + } + } + if link.UserID == uuid.Nil { //nolint:gocritic link, err = tx.InsertUserLink(dbauthz.AsSystemRestricted(ctx), database.InsertUserLinkParams{ From 41ac4d58cce74ef90843cdd85972e288e1927ac4 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 27 Jul 2023 12:22:05 +0200 Subject: [PATCH 28/69] Fix: refresh account status --- coderd/coderdtest/coderdtest.go | 6 ++++++ coderd/users.go | 2 +- enterprise/coderd/scim.go | 9 +++------ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 546bb60b1c1bd..a71290da5dbd9 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -581,6 +581,12 @@ func createAnotherUserRetry(t *testing.T, client *codersdk.Client, organizationI other.HTTPClient.CloseIdleConnections() }) + if user.Status == codersdk.UserStatusDormant { + // Refresh user account which should be active now. + user, err = other.User(context.Background(), user.Username) + require.NoError(t, err) + } + if len(roles) > 0 { // Find the roles for the org vs the site wide roles orgRoles := make(map[string][]string) diff --git a/coderd/users.go b/coderd/users.go index 5b5b3ef6d8ea8..aebc195f9d78c 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -631,7 +631,7 @@ func (api *API) putSuspendUserAccount() func(rw http.ResponseWriter, r *http.Req } // @Summary Mark user account as dormant -// @ID dormant-user-account +// @ID mark-user-account-as-dormant // @Security CoderSessionToken // @Produce json // @Tags Users diff --git a/enterprise/coderd/scim.go b/enterprise/coderd/scim.go index 026b71603ef32..efba55b932684 100644 --- a/enterprise/coderd/scim.go +++ b/enterprise/coderd/scim.go @@ -110,10 +110,9 @@ type SCIMUser struct { Type string `json:"type"` Display string `json:"display"` } `json:"emails"` - Active bool `json:"active"` - Dormant bool `json:"dormant"` - Groups []interface{} `json:"groups"` - Meta struct { + Active bool `json:"active"` + Groups []interface{} `json:"groups"` + Meta struct { ResourceType string `json:"resourceType"` } `json:"meta"` } @@ -265,8 +264,6 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) { var status database.UserStatus if sUser.Active { status = database.UserStatusActive - } else if sUser.Dormant { - status = database.UserStatusDormant } else { status = database.UserStatusSuspended } From bd875603a79723ba2afe69d027f3cc95803ddcdd Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 27 Jul 2023 12:41:34 +0200 Subject: [PATCH 29/69] Another fix --- coderd/coderdtest/coderdtest.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index a71290da5dbd9..fac831c30828b 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -581,7 +581,7 @@ func createAnotherUserRetry(t *testing.T, client *codersdk.Client, organizationI other.HTTPClient.CloseIdleConnections() }) - if user.Status == codersdk.UserStatusDormant { + if !req.DisableLogin && user.Status == codersdk.UserStatusDormant { // Refresh user account which should be active now. user, err = other.User(context.Background(), user.Username) require.NoError(t, err) From 9b7c40e0ad1f1083aaca757c46a8099d401f3976 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 27 Jul 2023 13:03:15 +0200 Subject: [PATCH 30/69] More fixes --- coderd/coderdtest/coderdtest.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index fac831c30828b..1dfcd552eb6cd 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -575,18 +575,20 @@ func createAnotherUserRetry(t *testing.T, client *codersdk.Client, organizationI sessionToken = token.Key } + if user.Status == codersdk.UserStatusDormant { + // Use admin client so that user's LastSeenAt is not updated. + // In general we need to refresh the user status, which should + // transition from "dormant" to "active". + user, err = client.User(context.Background(), user.Username) + require.NoError(t, err) + } + other := codersdk.New(client.URL) other.SetSessionToken(sessionToken) t.Cleanup(func() { other.HTTPClient.CloseIdleConnections() }) - if !req.DisableLogin && user.Status == codersdk.UserStatusDormant { - // Refresh user account which should be active now. - user, err = other.User(context.Background(), user.Username) - require.NoError(t, err) - } - if len(roles) > 0 { // Find the roles for the org vs the site wide roles orgRoles := make(map[string][]string) From 09b7a18a8393a8fda8e29decd6ec1cda590faeee Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 27 Jul 2023 13:07:01 +0200 Subject: [PATCH 31/69] docs --- coderd/apidoc/docs.go | 5 +---- coderd/apidoc/swagger.json | 5 +---- docs/api/enterprise.md | 3 --- docs/api/schemas.md | 2 -- 4 files changed, 2 insertions(+), 13 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a6197f8ea2118..37da649dc2ccd 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4148,7 +4148,7 @@ const docTemplate = `{ "Users" ], "summary": "Mark user account as dormant", - "operationId": "dormant-user-account", + "operationId": "mark-user-account-as-dormant", "parameters": [ { "type": "string", @@ -6651,9 +6651,6 @@ const docTemplate = `{ "active": { "type": "boolean" }, - "dormant": { - "type": "boolean" - }, "emails": { "type": "array", "items": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ffb6bf49aefc2..ac502215701d4 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3650,7 +3650,7 @@ "produces": ["application/json"], "tags": ["Users"], "summary": "Mark user account as dormant", - "operationId": "dormant-user-account", + "operationId": "mark-user-account-as-dormant", "parameters": [ { "type": "string", @@ -5901,9 +5901,6 @@ "active": { "type": "boolean" }, - "dormant": { - "type": "boolean" - }, "emails": { "type": "array", "items": { diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index 745c858dc84bd..03610ba53651f 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -821,7 +821,6 @@ curl -X POST http://coder-server:8080/api/v2/scim/v2/Users \ ```json { "active": true, - "dormant": true, "emails": [ { "display": "string", @@ -857,7 +856,6 @@ curl -X POST http://coder-server:8080/api/v2/scim/v2/Users \ ```json { "active": true, - "dormant": true, "emails": [ { "display": "string", @@ -933,7 +931,6 @@ curl -X PATCH http://coder-server:8080/api/v2/scim/v2/Users/{id} \ ```json { "active": true, - "dormant": true, "emails": [ { "display": "string", diff --git a/docs/api/schemas.md b/docs/api/schemas.md index d5a6150e21975..a7f0714262fd7 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -699,7 +699,6 @@ ```json { "active": true, - "dormant": true, "emails": [ { "display": "string", @@ -727,7 +726,6 @@ | Name | Type | Required | Restrictions | Description | | ---------------- | ------------------ | -------- | ------------ | ----------- | | `active` | boolean | false | | | -| `dormant` | boolean | false | | | | `emails` | array of object | false | | | | `» display` | string | false | | | | `» primary` | boolean | false | | | From ac43fcf11128cad0cc489fa7db3d5902bbd7a921 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 27 Jul 2023 13:08:15 +0200 Subject: [PATCH 32/69] make gen --- cli/testdata/coder_users_list_--output_json.golden | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/testdata/coder_users_list_--output_json.golden b/cli/testdata/coder_users_list_--output_json.golden index c3d8fe6695cd5..99595021a58d2 100644 --- a/cli/testdata/coder_users_list_--output_json.golden +++ b/cli/testdata/coder_users_list_--output_json.golden @@ -24,7 +24,7 @@ "email": "testuser2@coder.com", "created_at": "[timestamp]", "last_seen_at": "[timestamp]", - "status": "active", + "status": "dormant", "organization_ids": [ "[first org ID]" ], From 7b02cee242aeb5e2bb12423713fa987afbbbe317 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 27 Jul 2023 13:14:03 +0200 Subject: [PATCH 33/69] Fix: dbgen --- coderd/database/dbgen/dbgen.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 6b123cf4f3677..a74763c5d1e3e 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -224,6 +224,13 @@ func User(t testing.TB, db database.Store, orig database.User) database.User { }) require.NoError(t, err, "insert user") + user, err = db.UpdateUserStatus(genCtx, database.UpdateUserStatusParams{ + ID: user.ID, + Status: database.UserStatusActive, + UpdatedAt: database.Now(), + }) + require.NoError(t, err, "insert user") + if !orig.LastSeenAt.IsZero() { user, err = db.UpdateUserLastSeenAt(genCtx, database.UpdateUserLastSeenAtParams{ ID: user.ID, From c3dc08d569a81a63e0f3157302d309710ecc8580 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 27 Jul 2023 13:19:13 +0200 Subject: [PATCH 34/69] More fixes --- coderd/httpmw/authorize_test.go | 7 +++++++ coderd/httpmw/workspaceparam_test.go | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index 1418d0b65ffb9..b2491d3de4707 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -156,6 +156,13 @@ func addUser(t *testing.T, db database.Store, roles ...string) (database.User, s }) require.NoError(t, err) + user, err = db.UpdateUserStatus(context.Background(), database.UpdateUserStatusParams{ + ID: user.ID, + Status: database.UserStatusActive, + UpdatedAt: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertAPIKey(context.Background(), database.InsertAPIKeyParams{ ID: id, UserID: user.ID, diff --git a/coderd/httpmw/workspaceparam_test.go b/coderd/httpmw/workspaceparam_test.go index dd19abe8ebfcc..1c7504b793f64 100644 --- a/coderd/httpmw/workspaceparam_test.go +++ b/coderd/httpmw/workspaceparam_test.go @@ -48,6 +48,13 @@ func TestWorkspaceParam(t *testing.T) { }) require.NoError(t, err) + user, err = db.UpdateUserStatus(context.Background(), database.UpdateUserStatusParams{ + ID: user.ID, + Status: database.UserStatusActive, + UpdatedAt: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ ID: id, UserID: user.ID, From 4997479468ccaf212de84fb9e1378b63dd5c4a41 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 27 Jul 2023 13:36:32 +0200 Subject: [PATCH 35/69] Fix: entitlements tests --- enterprise/coderd/license/license_test.go | 27 +++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index b602c11172a65..546333b87a2af 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/require" "cdr.dev/slog" @@ -259,14 +260,36 @@ func TestEntitlements(t *testing.T) { t.Run("TooManyUsers", func(t *testing.T) { t.Parallel() db := dbfake.New() - db.InsertUser(context.Background(), database.InsertUserParams{ + activeUser1, err := db.InsertUser(context.Background(), database.InsertUserParams{ + ID: uuid.New(), Username: "test1", LoginType: database.LoginTypePassword, }) - db.InsertUser(context.Background(), database.InsertUserParams{ + require.NoError(t, err) + _, err = db.UpdateUserStatus(context.Background(), database.UpdateUserStatusParams{ + ID: activeUser1.ID, + Status: database.UserStatusActive, + UpdatedAt: database.Now(), + }) + require.NoError(t, err) + activeUser2, err := db.InsertUser(context.Background(), database.InsertUserParams{ + ID: uuid.New(), Username: "test2", LoginType: database.LoginTypePassword, }) + require.NoError(t, err) + _, err = db.UpdateUserStatus(context.Background(), database.UpdateUserStatusParams{ + ID: activeUser2.ID, + Status: database.UserStatusActive, + UpdatedAt: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertUser(context.Background(), database.InsertUserParams{ + ID: uuid.New(), + Username: "dormant-user", + LoginType: database.LoginTypePassword, + }) + require.NoError(t, err) db.InsertLicense(context.Background(), database.InsertLicenseParams{ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ Features: license.Features{ From 8f1ef7b94e0b9fdda12a8881d5213c7c4c6935ad Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 27 Jul 2023 13:53:04 +0200 Subject: [PATCH 36/69] Fix --- cli/userstatus_test.go | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/cli/userstatus_test.go b/cli/userstatus_test.go index d4fa9f536bf7e..15b38cb9b1c11 100644 --- a/cli/userstatus_test.go +++ b/cli/userstatus_test.go @@ -14,14 +14,13 @@ import ( func TestUserStatus(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) - admin := coderdtest.CreateFirstUser(t, client) - other, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) - otherUser, err := other.User(context.Background(), codersdk.Me) - require.NoError(t, err, "fetch user") t.Run("StatusSelf", func(t *testing.T) { t.Parallel() + + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + inv, root := clitest.New(t, "users", "suspend", "me") clitest.SetupConfig(t, client, root) // Yes to the prompt @@ -34,13 +33,18 @@ func TestUserStatus(t *testing.T) { t.Run("StatusOther", func(t *testing.T) { t.Parallel() - require.Equal(t, codersdk.UserStatusActive, otherUser.Status, "start as active") + + client := coderdtest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + other, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + otherUser, err := other.User(context.Background(), codersdk.Me) + require.NoError(t, err, "fetch user") inv, root := clitest.New(t, "users", "suspend", otherUser.Username) clitest.SetupConfig(t, client, root) // Yes to the prompt inv.Stdin = bytes.NewReader([]byte("yes\n")) - err := inv.Run() + err = inv.Run() require.NoError(t, err, "suspend user") // Check the user status @@ -64,13 +68,18 @@ func TestUserStatus(t *testing.T) { t.Run("StatusDormant", func(t *testing.T) { t.Parallel() - require.Equal(t, codersdk.UserStatusActive, otherUser.Status, "start as active") + + client := coderdtest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + other, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + otherUser, err := other.User(context.Background(), codersdk.Me) + require.NoError(t, err, "fetch user") inv, root := clitest.New(t, "users", "dormant", otherUser.Username) clitest.SetupConfig(t, client, root) // Yes to the prompt inv.Stdin = bytes.NewReader([]byte("yes\n")) - err := inv.Run() + err = inv.Run() require.NoError(t, err, "mark as dormant") // Check the user status From ec6e294352e9d83c49d0d894edb3e8840786f352 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 27 Jul 2023 14:24:59 +0200 Subject: [PATCH 37/69] Users Table --- .../UsersTable/UsersTable.stories.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/site/src/components/UsersTable/UsersTable.stories.tsx b/site/src/components/UsersTable/UsersTable.stories.tsx index 965ca671263d2..ce019b4d3e72b 100644 --- a/site/src/components/UsersTable/UsersTable.stories.tsx +++ b/site/src/components/UsersTable/UsersTable.stories.tsx @@ -25,7 +25,24 @@ Example.args = { export const Editable = Template.bind({}) Editable.args = { - users: [MockUser, MockUser2], + users: [ + MockUser, + MockUser2, + { + ...MockUser, + username: "John Doe", + email: "john.doe@coder.com", + roles: [], + status: "dormant", + }, + { + ...MockUser, + username: "Roger Moore", + email: "roger.moore@coder.com", + roles: [], + status: "suspended", + }, + ], roles: MockAssignableSiteRoles, canEditUsers: true, canViewActivity: true, From d04c6f203e0f9f542f0c611e6ddcfaf910d65487 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 27 Jul 2023 14:57:49 +0200 Subject: [PATCH 38/69] TS tests --- site/src/pages/UsersPage/UsersPage.test.tsx | 70 +++++++++++++++++++++ site/src/testHelpers/entities.ts | 13 ++++ 2 files changed, 83 insertions(+) diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index ef2ec64d44107..2fd44b705be2a 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -118,6 +118,32 @@ const activateUser = async (setupActionSpies: () => void) => { fireEvent.click(confirmButton) } +const markUserDormant = async (setupActionSpies: () => void) => { + const moreButtons = await screen.findAllByLabelText("more") + const markAsDormantMoreButton = moreButtons[1] + fireEvent.click(markAsDormantMoreButton) + + const menu = screen.getByRole("menu") + const text = t("markUserDormantMenuItem", { ns: "usersPage" }) + const markAsDormant = within(menu).getByText(text) + fireEvent.click(markAsDormant) + + // Check if the confirm message is displayed + const confirmDialog = screen.getByRole("dialog") + expect(confirmDialog).toHaveTextContent( + `Do you want to mark the user TestUser2 as dormant?`, + ) + + // Setup spies to check the actions after + setupActionSpies() + + // Click on the "Confirm" button + const confirmButton = within(confirmDialog).getByText( + UsersPageLanguage.markUserDormantDialogAction, + ) + fireEvent.click(confirmButton) +} + const resetUserPassword = async (setupActionSpies: () => void) => { const moreButtons = await screen.findAllByLabelText("more") const firstMoreButton = moreButtons[0] @@ -345,6 +371,50 @@ describe("UsersPage", () => { }) }) + describe("mark user as dormant", () => { + describe("when user is successfully marked as dormant", () => { + it("shows a success message and refreshes the page", async () => { + renderPage() + + await markUserDormant(() => { + jest.spyOn(API, "markUserDormant").mockResolvedValueOnce({ + ...MockUser2, + status: "dormant", + }) + jest.spyOn(API, "getUsers").mockImplementationOnce(() => + Promise.resolve({ + users: [MockUser, MockUser2], + count: 2, + }), + ) + }) + + // Check if the success message is displayed + await screen.findByText(usersXServiceLanguage.markUserDormantSuccess) + + // Check if the API was called correctly + expect(API.markUserDormant).toBeCalledTimes(1) + expect(API.markUserDormant).toBeCalledWith(MockUser2.id) + }) + }) + describe("when can't mark user as dormant", () => { + it("shows an error message", async () => { + renderPage() + + await markUserDormant(() => { + jest.spyOn(API, "markUserDormant").mockRejectedValueOnce({}) + }) + + // Check if the error message is displayed + await screen.findByText(usersXServiceLanguage.markUserDormantError) + + // Check if the API was called correctly + expect(API.markUserDormant).toBeCalledTimes(1) + expect(API.markUserDormant).toBeCalledWith(MockUser2.id) + }) + }) + }) + describe("reset user password", () => { describe("when it is success", () => { it("shows a success message", async () => { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index a8e1baaf0056f..3ae93275f38d4 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -314,6 +314,19 @@ export const SuspendedMockUser: TypesGen.User = { login_type: "password", } +export const DormantMockUser: TypesGen.User = { + id: "dormant-mock-user", + username: "DormantMockUser", + email: "iamdormant@coder.com", + created_at: "", + status: "dormant", + organization_ids: [MockOrganization.id], + roles: [], + avatar_url: "", + last_seen_at: "", + login_type: "password", +} + export const MockProvisioner: TypesGen.ProvisionerDaemon = { created_at: "", id: "test-provisioner", From 4f2cdc3320fc2c1ff711bb60e942a624e3502779 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 27 Jul 2023 15:27:43 +0200 Subject: [PATCH 39/69] Docs --- docs/admin/users.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/admin/users.md b/docs/admin/users.md index b85a4256110d1..4f6c6d2d951ea 100644 --- a/docs/admin/users.md +++ b/docs/admin/users.md @@ -26,6 +26,31 @@ A malicious Template Admin could write a template that executes commands on the In low-trust environments, we do not recommend giving users direct access to edit templates. Instead, use [CI/CD pipelines to update templates](../templates/change-management.md) with proper security scans and code reviews in place. +## User status + +Coder user accounts can have different status types: active, dormant, and suspended. + +### Active user + +An _active_ user account in Coder is the default and desired state for all users. When a user's account is marked as _active_, they have complete access to the Coder platform +and can utilize all of its features and functionalities without any limitations. Active users can access workspaces, templates, and interact with Coder using CLI. + +### Dormant user + +A user account is set to _dormant_ status when the user has not yet logged in or been active on the Coder platform. + +This status is typically used to signify that the user account has been created but user has not taken any actions within the platform. Once the user logs in to Coder, the account status will switch to _active_. + +Dormant accounts do not count towards the total number of licensed seats in a Coder subscription, allowing organizations to optimize their license usage. It is important to note that the _dormant_ +status does not restrict the user from being activated manually. User administrators have the ability to activate any dormant account, granting users access to Coder's features. + +### Suspended user + +When a user's account is marked as _suspended_ in Coder, it means that the account has been temporarily deactivated, and the user is unable to access the platform. + +Only user administrators or owners have the necessary permissions to manage suspended accounts and decide whether to lift the suspension and allow the user back into the Coder environment. +This level of control ensures that administrators can enforce security measures and handle any compliance-related issues promptly. + ## Create a user To create a user with the web UI: @@ -139,5 +164,5 @@ In the Coder UI, you can filter your users using pre-defined filters or by utili The following filters are supported: -- `status` - Indicates the status of the user. It can be either `active` or `suspended`. +- `status` - Indicates the status of the user. It can be either `active`, `dormant` or `suspended`. - `role` - Represents the role of the user. You can refer to the [TemplateRole documentation](https://pkg.go.dev/github.com/coder/coder/codersdk#TemplateRole) for a list of supported user roles. From 63eadcbfd2000f492c0b4083bc9fdc1d023f2a90 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 28 Jul 2023 12:10:28 +0200 Subject: [PATCH 40/69] Fix: apikey --- coder.env | 11 ----------- coderd/httpmw/apikey.go | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 11 deletions(-) delete mode 100644 coder.env diff --git a/coder.env b/coder.env deleted file mode 100644 index 0c198649e0ee6..0000000000000 --- a/coder.env +++ /dev/null @@ -1,11 +0,0 @@ -# Coder must be reachable from an external URL for users and workspaces to connect. -# e.g. https://coder.example.com -CODER_ACCESS_URL= - -CODER_ADDRESS= -CODER_PG_CONNECTION_URL= -CODER_TLS_CERT_FILE= -CODER_TLS_ENABLE= -CODER_TLS_KEY_FILE= - -# Run "coder server --help" for flag information. diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 3de05b073ced5..e06f23048b144 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -393,6 +393,21 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon }) } + if roles.Status == database.UserStatusDormant { + u, err := cfg.DB.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{ + ID: key.UserID, + Status: database.UserStatusActive, + UpdatedAt: database.Now(), + }) + if err != nil { + return write(http.StatusInternalServerError, codersdk.Response{ + Message: internalErrorMessage, + Detail: fmt.Sprintf("can't activate a dormant user: %s", err.Error()), + }) + } + roles.Status = u.Status + } + if roles.Status != database.UserStatusActive { return write(http.StatusUnauthorized, codersdk.Response{ Message: fmt.Sprintf("User is not active (status = %q). Contact an admin to reactivate your account.", roles.Status), From 699ff5a0cc6f078d433f9aa6cf0c59b1734e4f2a Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 28 Jul 2023 12:25:45 +0200 Subject: [PATCH 41/69] lint --- coderd/httpmw/apikey.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index e06f23048b144..5f0ec0dc263c7 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -394,6 +394,8 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon } if roles.Status == database.UserStatusDormant { + // If coder confirms that the dormant user is valid, it can switch their account to active. + // nolint:gocritic u, err := cfg.DB.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{ ID: key.UserID, Status: database.UserStatusActive, From e0b33b3c34e1b51701a55176a1913380b9db18bd Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 28 Jul 2023 12:59:39 +0200 Subject: [PATCH 42/69] fix? --- site/src/xServices/users/usersXService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/xServices/users/usersXService.ts b/site/src/xServices/users/usersXService.ts index 816954dd2f43f..9b6442075690c 100644 --- a/site/src/xServices/users/usersXService.ts +++ b/site/src/xServices/users/usersXService.ts @@ -53,7 +53,7 @@ export interface UsersContext { // Mark user dormant userIdToMarkDormant?: TypesGen.User["id"] usernameToMarkDormant?: TypesGen.User["username"] - markUserDormantError?: Error | unknown + markUserDormantError?: unknown // Reset user password userIdToResetPassword?: TypesGen.User["id"] resetUserPasswordError?: unknown From eb1aaa0eadd7ed27e6b865436d2b0c6a53f4f4a2 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 1 Aug 2023 09:34:50 +0200 Subject: [PATCH 43/69] fix --- ...tatus_dormant.down.sql => 000144_user_status_dormant.down.sql} | 0 ...er_status_dormant.up.sql => 000144_user_status_dormant.up.sql} | 0 ...rmant.down.sql => 000145_user_status_default_dormant.down.sql} | 0 ...t_dormant.up.sql => 000145_user_status_default_dormant.up.sql} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000143_user_status_dormant.down.sql => 000144_user_status_dormant.down.sql} (100%) rename coderd/database/migrations/{000143_user_status_dormant.up.sql => 000144_user_status_dormant.up.sql} (100%) rename coderd/database/migrations/{000144_user_status_default_dormant.down.sql => 000145_user_status_default_dormant.down.sql} (100%) rename coderd/database/migrations/{000144_user_status_default_dormant.up.sql => 000145_user_status_default_dormant.up.sql} (100%) diff --git a/coderd/database/migrations/000143_user_status_dormant.down.sql b/coderd/database/migrations/000144_user_status_dormant.down.sql similarity index 100% rename from coderd/database/migrations/000143_user_status_dormant.down.sql rename to coderd/database/migrations/000144_user_status_dormant.down.sql diff --git a/coderd/database/migrations/000143_user_status_dormant.up.sql b/coderd/database/migrations/000144_user_status_dormant.up.sql similarity index 100% rename from coderd/database/migrations/000143_user_status_dormant.up.sql rename to coderd/database/migrations/000144_user_status_dormant.up.sql diff --git a/coderd/database/migrations/000144_user_status_default_dormant.down.sql b/coderd/database/migrations/000145_user_status_default_dormant.down.sql similarity index 100% rename from coderd/database/migrations/000144_user_status_default_dormant.down.sql rename to coderd/database/migrations/000145_user_status_default_dormant.down.sql diff --git a/coderd/database/migrations/000144_user_status_default_dormant.up.sql b/coderd/database/migrations/000145_user_status_default_dormant.up.sql similarity index 100% rename from coderd/database/migrations/000144_user_status_default_dormant.up.sql rename to coderd/database/migrations/000145_user_status_default_dormant.up.sql From 6fcec6c77dce66d218c56d72b53b215f3f81df25 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 1 Aug 2023 09:51:54 +0200 Subject: [PATCH 44/69] Rip: update user status to dormant --- cli/testdata/coder_users_--help.golden | 18 ++++----- cli/users.go | 1 - cli/userstatus.go | 17 ++------ cli/userstatus_test.go | 35 ---------------- coderd/apidoc/docs.go | 34 ---------------- coderd/apidoc/swagger.json | 30 -------------- coderd/coderd.go | 1 - coderd/users.go | 12 ------ coderd/users_test.go | 55 -------------------------- codersdk/users.go | 2 - docs/api/users.md | 51 ------------------------ docs/cli/users.md | 15 ++++--- docs/cli/users_mark-as-dormant.md | 32 --------------- docs/manifest.json | 5 --- 14 files changed, 18 insertions(+), 290 deletions(-) delete mode 100644 docs/cli/users_mark-as-dormant.md diff --git a/cli/testdata/coder_users_--help.golden b/cli/testdata/coder_users_--help.golden index 57ebe305bd195..d4774b4406022 100644 --- a/cli/testdata/coder_users_--help.golden +++ b/cli/testdata/coder_users_--help.golden @@ -5,16 +5,14 @@ Manage users Aliases: user Subcommands - activate Update a user's status to 'active'. Active users can - fully interact with the platform - create - list - mark-as-dormant Update a user's status to 'dormant'. Dormant users are - not counted in the license plan - show Show a single user. Use 'me' to indicate the currently - authenticated user. - suspend Update a user's status to 'suspended'. A suspended user - cannot log into the platform + activate Update a user's status to 'active'. Active users can fully + interact with the platform + create + list + show Show a single user. Use 'me' to indicate the currently + authenticated user. + suspend Update a user's status to 'suspended'. A suspended user cannot + log into the platform --- Run `coder --help` for a list of global options. diff --git a/cli/users.go b/cli/users.go index 63f559bb970ef..76615d05d7f04 100644 --- a/cli/users.go +++ b/cli/users.go @@ -18,7 +18,6 @@ func (r *RootCmd) users() *clibase.Cmd { r.userList(), r.userSingle(), r.createUserStatusCommand(codersdk.UserStatusActive), - r.createUserStatusCommand(codersdk.UserStatusDormant), r.createUserStatusCommand(codersdk.UserStatusSuspended), }, } diff --git a/cli/userstatus.go b/cli/userstatus.go index 0ff736dff1121..6a2ada1a7cd19 100644 --- a/cli/userstatus.go +++ b/cli/userstatus.go @@ -23,11 +23,6 @@ func (r *RootCmd) createUserStatusCommand(sdkStatus codersdk.UserStatus) *clibas pastVerb = "activated" aliases = []string{"active"} short = "Update a user's status to 'active'. Active users can fully interact with the platform" - case codersdk.UserStatusDormant: - verb = "mark as dormant" - pastVerb = "marked as dormant" - aliases = []string{"dormant"} - short = "Update a user's status to 'dormant'. Dormant users are not counted in the license plan" case codersdk.UserStatusSuspended: verb = "suspend" pastVerb = "suspended" @@ -41,12 +36,12 @@ func (r *RootCmd) createUserStatusCommand(sdkStatus codersdk.UserStatus) *clibas var columns []string cmd := &clibase.Cmd{ - Use: fmt.Sprintf("%s ", strings.ReplaceAll(verb, " ", "-")), + Use: fmt.Sprintf("%s ", verb), Short: short, Aliases: aliases, Long: formatExamples( example{ - Command: fmt.Sprintf("coder users %s example_user", strings.ReplaceAll(verb, " ", "-")), + Command: fmt.Sprintf("coder users %s example_user", verb), }, ), Middleware: clibase.Chain( @@ -80,14 +75,8 @@ func (r *RootCmd) createUserStatusCommand(sdkStatus codersdk.UserStatus) *clibas } // Prompt to confirm the action - var question string - if sdkStatus == codersdk.UserStatusDormant { - question = "Are you sure you want to mark this user as dormant?" - } else { - question = fmt.Sprintf("Are you sure you want to %s this user?", verb) - } _, err = cliui.Prompt(inv, cliui.PromptOptions{ - Text: question, + Text: fmt.Sprintf("Are you sure you want to %s this user?", verb), IsConfirm: true, Default: cliui.ConfirmYes, }) diff --git a/cli/userstatus_test.go b/cli/userstatus_test.go index 15b38cb9b1c11..348559e10de5d 100644 --- a/cli/userstatus_test.go +++ b/cli/userstatus_test.go @@ -65,39 +65,4 @@ func TestUserStatus(t *testing.T) { require.NoError(t, err, "fetch active user") require.Equal(t, codersdk.UserStatusActive, otherUser.Status, "active user") }) - - t.Run("StatusDormant", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, nil) - admin := coderdtest.CreateFirstUser(t, client) - other, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) - otherUser, err := other.User(context.Background(), codersdk.Me) - require.NoError(t, err, "fetch user") - - inv, root := clitest.New(t, "users", "dormant", otherUser.Username) - clitest.SetupConfig(t, client, root) - // Yes to the prompt - inv.Stdin = bytes.NewReader([]byte("yes\n")) - err = inv.Run() - require.NoError(t, err, "mark as dormant") - - // Check the user status - otherUser, err = client.User(context.Background(), otherUser.Username) - require.NoError(t, err, "fetch dormant user") - require.Equal(t, codersdk.UserStatusDormant, otherUser.Status, "marked as dormant") - - // Set back to active. Try using a uuid as well - inv, root = clitest.New(t, "users", "activate", otherUser.ID.String()) - clitest.SetupConfig(t, client, root) - // Yes to the prompt - inv.Stdin = bytes.NewReader([]byte("yes\n")) - err = inv.Run() - require.NoError(t, err, "suspend user") - - // Check the user status - otherUser, err = client.User(context.Background(), otherUser.ID.String()) - require.NoError(t, err, "fetch active user") - require.Equal(t, codersdk.UserStatusActive, otherUser.Status, "active user") - }) } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5e2603917a116..cc93963975f9a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4134,40 +4134,6 @@ const docTemplate = `{ } } }, - "/users/{user}/status/dormant": { - "put": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "Mark user account as dormant", - "operationId": "mark-user-account-as-dormant", - "parameters": [ - { - "type": "string", - "description": "User ID, name, or me", - "name": "user", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.User" - } - } - } - } - }, "/users/{user}/status/suspend": { "put": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 713ac817d26d1..04d819204af86 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3640,36 +3640,6 @@ } } }, - "/users/{user}/status/dormant": { - "put": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": ["application/json"], - "tags": ["Users"], - "summary": "Mark user account as dormant", - "operationId": "mark-user-account-as-dormant", - "parameters": [ - { - "type": "string", - "description": "User ID, name, or me", - "name": "user", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.User" - } - } - } - } - }, "/users/{user}/status/suspend": { "put": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index ed3f834a2bc7a..d7b80ff273097 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -717,7 +717,6 @@ func New(options *Options) *API { r.Route("/status", func(r chi.Router) { r.Put("/suspend", api.putSuspendUserAccount()) r.Put("/activate", api.putActivateUserAccount()) - r.Put("/dormant", api.putDormantUserAccount()) }) r.Route("/password", func(r chi.Router) { r.Put("/", api.putUserPassword) diff --git a/coderd/users.go b/coderd/users.go index aebc195f9d78c..f5d32711fb207 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -630,18 +630,6 @@ func (api *API) putSuspendUserAccount() func(rw http.ResponseWriter, r *http.Req return api.putUserStatus(database.UserStatusSuspended) } -// @Summary Mark user account as dormant -// @ID mark-user-account-as-dormant -// @Security CoderSessionToken -// @Produce json -// @Tags Users -// @Param user path string true "User ID, name, or me" -// @Success 200 {object} codersdk.User -// @Router /users/{user}/status/dormant [put] -func (api *API) putDormantUserAccount() func(rw http.ResponseWriter, r *http.Request) { - return api.putUserStatus(database.UserStatusDormant) -} - // @Summary Activate user account // @ID activate-user-account // @Security CoderSessionToken diff --git a/coderd/users_test.go b/coderd/users_test.go index 9aa163363eebc..eff3174ad83a2 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1048,61 +1048,6 @@ func TestPutUserSuspend(t *testing.T) { }) } -func TestPutUserDormant(t *testing.T) { - t.Parallel() - - t.Run("MarkOwnerAsDormant", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - me := coderdtest.CreateFirstUser(t, client) - _, user := coderdtest.CreateAnotherUser(t, client, me.OrganizationID, rbac.RoleOwner()) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - _, err := client.UpdateUserStatus(ctx, user.Username, codersdk.UserStatusDormant) - require.Error(t, err, "cannot mark owners as dormant") - }) - - t.Run("MarkAnotherUserDormant", func(t *testing.T) { - t.Parallel() - auditor := audit.NewMock() - client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) - numLogs := len(auditor.AuditLogs()) - - me := coderdtest.CreateFirstUser(t, client) - numLogs++ // add an audit log for user create - numLogs++ // add an audit log for login - - _, user := coderdtest.CreateAnotherUser(t, client, me.OrganizationID) - numLogs++ // add an audit log for user create - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - user, err := client.UpdateUserStatus(ctx, user.Username, codersdk.UserStatusDormant) - require.NoError(t, err) - require.Equal(t, user.Status, codersdk.UserStatusDormant) - numLogs++ // add an audit log for user update - - require.Len(t, auditor.AuditLogs(), numLogs) - require.Equal(t, database.AuditActionWrite, auditor.AuditLogs()[numLogs-1].Action) - }) - - t.Run("MarkOwnUserDormant", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - coderdtest.CreateFirstUser(t, client) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - _, err := client.UpdateUserStatus(ctx, codersdk.Me, codersdk.UserStatusDormant) - - require.ErrorContains(t, err, "cannot mark own user as dormant") - }) -} - func TestActivateDormantUser(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/codersdk/users.go b/codersdk/users.go index 313026a23069c..daeefee5f12bf 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -258,8 +258,6 @@ func (c *Client) UpdateUserStatus(ctx context.Context, user string, status UserS switch status { case UserStatusActive: path += "activate" - case UserStatusDormant: - path += "dormant" case UserStatusSuspended: path += "suspend" default: diff --git a/docs/api/users.md b/docs/api/users.md index 131f2538c12c8..3c583e15787db 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -1189,57 +1189,6 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/activate \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Mark user account as dormant - -### Code samples - -```shell -# Example request using curl -curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/dormant \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`PUT /users/{user}/status/dormant` - -### Parameters - -| Name | In | Type | Required | Description | -| ------ | ---- | ------ | -------- | -------------------- | -| `user` | path | string | true | User ID, name, or me | - -### Example responses - -> 200 Response - -```json -{ - "avatar_url": "http://example.com", - "created_at": "2019-08-24T14:15:22Z", - "email": "user@example.com", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", - "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], - "roles": [ - { - "display_name": "string", - "name": "string" - } - ], - "status": "active", - "username": "string" -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.User](schemas.md#codersdkuser) | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - ## Suspend user account ### Code samples diff --git a/docs/cli/users.md b/docs/cli/users.md index e075512deda08..ade49b04a866b 100644 --- a/docs/cli/users.md +++ b/docs/cli/users.md @@ -16,11 +16,10 @@ coder users [subcommand] ## Subcommands -| Name | Purpose | -| ---------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| [activate](./users_activate.md) | Update a user's status to 'active'. Active users can fully interact with the platform | -| [create](./users_create.md) | | -| [list](./users_list.md) | | -| [mark-as-dormant](./users_mark-as-dormant.md) | Update a user's status to 'dormant'. Dormant users are not counted in the license plan | -| [show](./users_show.md) | Show a single user. Use 'me' to indicate the currently authenticated user. | -| [suspend](./users_suspend.md) | Update a user's status to 'suspended'. A suspended user cannot log into the platform | +| Name | Purpose | +| -------------------------------------------- | ------------------------------------------------------------------------------------- | +| [activate](./users_activate.md) | Update a user's status to 'active'. Active users can fully interact with the platform | +| [create](./users_create.md) | | +| [list](./users_list.md) | | +| [show](./users_show.md) | Show a single user. Use 'me' to indicate the currently authenticated user. | +| [suspend](./users_suspend.md) | Update a user's status to 'suspended'. A suspended user cannot log into the platform | diff --git a/docs/cli/users_mark-as-dormant.md b/docs/cli/users_mark-as-dormant.md deleted file mode 100644 index e2c3cd586dd6b..0000000000000 --- a/docs/cli/users_mark-as-dormant.md +++ /dev/null @@ -1,32 +0,0 @@ - - -# users mark-as-dormant - -Update a user's status to 'dormant'. Dormant users are not counted in the license plan - -Aliases: - -- dormant - -## Usage - -```console -coder users mark-as-dormant [flags] -``` - -## Description - -```console - $ coder users mark-as-dormant example_user -``` - -## Options - -### -c, --column - -| | | -| ------- | --------------------------------------------- | -| Type | string-array | -| Default | username,email,created_at,status | - -Specify a column to filter in the table. diff --git a/docs/manifest.json b/docs/manifest.json index 64128dfb6d467..d6c59b477f053 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -856,11 +856,6 @@ "title": "users list", "path": "cli/users_list.md" }, - { - "title": "users mark-as-dormant", - "description": "Update a user's status to 'dormant'. Dormant users are not counted in the license plan", - "path": "cli/users_mark-as-dormant.md" - }, { "title": "users show", "description": "Show a single user. Use 'me' to indicate the currently authenticated user.", From 2b20a2631bd09a7041b17072cad55f719c030ea6 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 1 Aug 2023 10:01:56 +0200 Subject: [PATCH 45/69] More trimming --- site/src/api/api.ts | 9 --- site/src/i18n/en/usersPage.json | 1 - site/src/pages/UsersPage/UsersPage.test.tsx | 70 ------------------ site/src/pages/UsersPage/UsersPage.tsx | 36 --------- site/src/pages/UsersPage/UsersPageView.tsx | 3 - site/src/testHelpers/entities.ts | 13 ---- site/src/xServices/users/usersXService.ts | 81 --------------------- 7 files changed, 213 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 943ef9e7c8f7a..a1ac0a16366e6 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -661,15 +661,6 @@ export const activateUser = async ( return response.data } -export const markUserDormant = async ( - userId: TypesGen.User["id"], -): Promise => { - const response = await axios.put( - `/api/v2/users/${userId}/status/dormant`, - ) - return response.data -} - export const suspendUser = async ( userId: TypesGen.User["id"], ): Promise => { diff --git a/site/src/i18n/en/usersPage.json b/site/src/i18n/en/usersPage.json index c766199c84558..94b3cec39b330 100644 --- a/site/src/i18n/en/usersPage.json +++ b/site/src/i18n/en/usersPage.json @@ -5,7 +5,6 @@ "deleteMenuItem": "Delete", "listWorkspacesMenuItem": "View workspaces", "activateMenuItem": "Activate", - "markUserDormantMenuItem": "Mark as dormant", "resetPasswordMenuItem": "Reset password", "editUserRolesTooltip": "Edit user roles", "fieldSetRolesTooltip": "Available roles", diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index 2fd44b705be2a..ef2ec64d44107 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -118,32 +118,6 @@ const activateUser = async (setupActionSpies: () => void) => { fireEvent.click(confirmButton) } -const markUserDormant = async (setupActionSpies: () => void) => { - const moreButtons = await screen.findAllByLabelText("more") - const markAsDormantMoreButton = moreButtons[1] - fireEvent.click(markAsDormantMoreButton) - - const menu = screen.getByRole("menu") - const text = t("markUserDormantMenuItem", { ns: "usersPage" }) - const markAsDormant = within(menu).getByText(text) - fireEvent.click(markAsDormant) - - // Check if the confirm message is displayed - const confirmDialog = screen.getByRole("dialog") - expect(confirmDialog).toHaveTextContent( - `Do you want to mark the user TestUser2 as dormant?`, - ) - - // Setup spies to check the actions after - setupActionSpies() - - // Click on the "Confirm" button - const confirmButton = within(confirmDialog).getByText( - UsersPageLanguage.markUserDormantDialogAction, - ) - fireEvent.click(confirmButton) -} - const resetUserPassword = async (setupActionSpies: () => void) => { const moreButtons = await screen.findAllByLabelText("more") const firstMoreButton = moreButtons[0] @@ -371,50 +345,6 @@ describe("UsersPage", () => { }) }) - describe("mark user as dormant", () => { - describe("when user is successfully marked as dormant", () => { - it("shows a success message and refreshes the page", async () => { - renderPage() - - await markUserDormant(() => { - jest.spyOn(API, "markUserDormant").mockResolvedValueOnce({ - ...MockUser2, - status: "dormant", - }) - jest.spyOn(API, "getUsers").mockImplementationOnce(() => - Promise.resolve({ - users: [MockUser, MockUser2], - count: 2, - }), - ) - }) - - // Check if the success message is displayed - await screen.findByText(usersXServiceLanguage.markUserDormantSuccess) - - // Check if the API was called correctly - expect(API.markUserDormant).toBeCalledTimes(1) - expect(API.markUserDormant).toBeCalledWith(MockUser2.id) - }) - }) - describe("when can't mark user as dormant", () => { - it("shows an error message", async () => { - renderPage() - - await markUserDormant(() => { - jest.spyOn(API, "markUserDormant").mockRejectedValueOnce({}) - }) - - // Check if the error message is displayed - await screen.findByText(usersXServiceLanguage.markUserDormantError) - - // Check if the API was called correctly - expect(API.markUserDormant).toBeCalledTimes(1) - expect(API.markUserDormant).toBeCalledWith(MockUser2.id) - }) - }) - }) - describe("reset user password", () => { describe("when it is success", () => { it("shows a success message", async () => { diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 5d1d36f36d88c..67bdc9d4ae20c 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -29,10 +29,6 @@ export const Language = { activateDialogTitle: "Activate user", activateDialogAction: "Activate", activateDialogMessagePrefix: "Do you want to activate the user", - markUserDormantDialogTitle: "Mark user as dormant", - markUserDormantDialogAction: "Mark as dormant", - markUserDormantDialogMessagePrefix: - "Do you want to mark the user account as dormant", } const getSelectedUser = (id: string, users?: User[]) => @@ -60,7 +56,6 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { usernameToDelete, usernameToSuspend, usernameToActivate, - usernameToMarkDormant, userIdToResetPassword, newUserPassword, paginationRef, @@ -154,13 +149,6 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { username: user.username, }) }} - onMarkUserDormant={(user) => { - usersSend({ - type: "MARK_USER_DORMANT", - userId: user.id, - username: user.username, - }) - }} onResetUserPassword={(user) => { usersSend({ type: "RESET_USER_PASSWORD", userId: user.id }) }} @@ -254,30 +242,6 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { } /> - { - usersSend("CONFIRM_USER_DORMANT") - }} - onClose={() => { - usersSend("CANCEL_USER_DORMANT") - }} - description={ - <> - Do you want to mark the user{" "} - {usernameToMarkDormant ?? ""} as dormant? - - } - /> - {userIdToResetPassword && ( void onViewActivity: (user: TypesGen.User) => void onActivateUser: (user: TypesGen.User) => void - onMarkUserDormant: (user: TypesGen.User) => void onResetUserPassword: (user: TypesGen.User) => void onUpdateUserRoles: ( user: TypesGen.User, @@ -45,7 +44,6 @@ export const UsersPageView: FC> = ({ onListWorkspaces, onViewActivity, onActivateUser, - onMarkUserDormant, onResetUserPassword, onUpdateUserRoles, isUpdatingUserRoles, @@ -77,7 +75,6 @@ export const UsersPageView: FC> = ({ onListWorkspaces={onListWorkspaces} onViewActivity={onViewActivity} onActivateUser={onActivateUser} - onMarkUserDormant={onMarkUserDormant} onResetUserPassword={onResetUserPassword} onUpdateUserRoles={onUpdateUserRoles} isUpdatingUserRoles={isUpdatingUserRoles} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 561d886c2b653..930263fe2049c 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -314,19 +314,6 @@ export const SuspendedMockUser: TypesGen.User = { login_type: "password", } -export const DormantMockUser: TypesGen.User = { - id: "dormant-mock-user", - username: "DormantMockUser", - email: "iamdormant@coder.com", - created_at: "", - status: "dormant", - organization_ids: [MockOrganization.id], - roles: [], - avatar_url: "", - last_seen_at: "", - login_type: "password", -} - export const MockProvisioner: TypesGen.ProvisionerDaemon = { created_at: "", id: "test-provisioner", diff --git a/site/src/xServices/users/usersXService.ts b/site/src/xServices/users/usersXService.ts index 9b6442075690c..7b5f2f12b5de9 100644 --- a/site/src/xServices/users/usersXService.ts +++ b/site/src/xServices/users/usersXService.ts @@ -25,8 +25,6 @@ export const Language = { deleteUserError: "Error deleting user.", activateUserSuccess: "Successfully activated the user.", activateUserError: "Error activating user.", - markUserDormantSuccess: "Successfully marked the user account as dormant.", - markUserDormantError: "Error marking user account as dormant.", resetUserPasswordSuccess: "Successfully updated the user password.", resetUserPasswordError: "Error on resetting the user password.", updateUserRolesSuccess: "Successfully updated the user roles.", @@ -50,10 +48,6 @@ export interface UsersContext { userIdToActivate?: TypesGen.User["id"] usernameToActivate?: TypesGen.User["username"] activateUserError?: unknown - // Mark user dormant - userIdToMarkDormant?: TypesGen.User["id"] - usernameToMarkDormant?: TypesGen.User["username"] - markUserDormantError?: unknown // Reset user password userIdToResetPassword?: TypesGen.User["id"] resetUserPasswordError?: unknown @@ -92,14 +86,6 @@ export type UsersEvent = } | { type: "CONFIRM_USER_ACTIVATION" } | { type: "CANCEL_USER_ACTIVATION" } - // Mark as dormant events - | { - type: "MARK_USER_DORMANT" - userId: TypesGen.User["id"] - username: TypesGen.User["username"] - } - | { type: "CONFIRM_USER_DORMANT" } - | { type: "CANCEL_USER_DORMANT" } // Reset password events | { type: "RESET_USER_PASSWORD"; userId: TypesGen.User["id"] } | { type: "CONFIRM_USER_PASSWORD_RESET" } @@ -139,9 +125,6 @@ export const usersMachine = activateUser: { data: TypesGen.User } - markUserDormant: { - data: TypesGen.User - } updateUserPassword: { data: undefined } @@ -204,10 +187,6 @@ export const usersMachine = target: "confirmUserActivation", actions: "assignUserToActivate", }, - MARK_USER_DORMANT: { - target: "confirmUserDormant", - actions: "assignUserToMarkDormant", - }, RESET_USER_PASSWORD: { target: "confirmUserPasswordReset", actions: [ @@ -251,16 +230,6 @@ export const usersMachine = }, }, }, - confirmUserDormant: { - on: { - CONFIRM_USER_DORMANT: { - target: "markingUserDormant", - }, - CANCEL_USER_DORMANT: { - target: "idle", - }, - }, - }, suspendingUser: { entry: "clearSuspendUserError", invoke: { @@ -324,28 +293,6 @@ export const usersMachine = ], }, }, - markingUserDormant: { - entry: "clearMarkUserDormantError", - invoke: { - src: "markUserDormant", - id: "markUserDormant", - onDone: [ - { - target: "gettingUsers", - actions: "displayMarkUserDormantSuccess", - }, - ], - onError: [ - { - target: "idle", - actions: [ - "assignMarkUserDormantError", - "displayMarkUserDormantErrorMessage", - ], - }, - ], - }, - }, confirmUserPasswordReset: { on: { CONFIRM_USER_PASSWORD_RESET: { @@ -435,12 +382,6 @@ export const usersMachine = return API.activateUser(context.userIdToActivate) }, - markUserDormant: (context) => { - if (!context.userIdToMarkDormant) { - throw new Error("userIdToMarkDormant is undefined") - } - return API.markUserDormant(context.userIdToMarkDormant) - }, resetUserPassword: (context) => { if (!context.userIdToResetPassword) { throw new Error("userIdToResetPassword is undefined") @@ -472,8 +413,6 @@ export const usersMachine = usernameToDelete: (_) => undefined, userIdToActivate: (_) => undefined, usernameToActivate: (_) => undefined, - userIdToMarkDormant: (_) => undefined, - usernameToMarkDormant: (_) => undefined, userIdToResetPassword: (_) => undefined, userIdToUpdateRoles: (_) => undefined, }), @@ -499,10 +438,6 @@ export const usersMachine = userIdToActivate: (_, event) => event.userId, usernameToActivate: (_, event) => event.username, }), - assignUserToMarkDormant: assign({ - userIdToMarkDormant: (_, event) => event.userId, - usernameToMarkDormant: (_, event) => event.username, - }), assignUserIdToResetPassword: assign({ userIdToResetPassword: (_, event) => event.userId, }), @@ -522,9 +457,6 @@ export const usersMachine = assignActivateUserError: assign({ activateUserError: (_, event) => event.data, }), - assignMarkUserDormantError: assign({ - markUserDormantError: (_, event) => event.data, - }), assignResetUserPasswordError: assign({ resetUserPasswordError: (_, event) => event.data, }), @@ -545,9 +477,6 @@ export const usersMachine = clearActivateUserError: assign({ activateUserError: (_) => undefined, }), - clearMarkUserDormantError: assign({ - markUserDormantError: (_) => undefined, - }), clearResetUserPasswordError: assign({ resetUserPasswordError: (_) => undefined, }), @@ -584,16 +513,6 @@ export const usersMachine = ) displayError(message) }, - displayMarkUserDormantSuccess: () => { - displaySuccess(Language.markUserDormantSuccess) - }, - displayMarkUserDormantErrorMessage: (context) => { - const message = getErrorMessage( - context.markUserDormantError, - Language.markUserDormantError, - ) - displayError(message) - }, displayResetPasswordSuccess: () => { displaySuccess(Language.resetUserPasswordSuccess) }, From adaecab733b966f78e743d452d6cba60acf22f00 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 1 Aug 2023 10:03:56 +0200 Subject: [PATCH 46/69] More trimming --- .../UsersTable/UsersTable.stories.tsx | 19 +------------ site/src/components/UsersTable/UsersTable.tsx | 3 --- .../components/UsersTable/UsersTableBody.tsx | 27 ------------------- 3 files changed, 1 insertion(+), 48 deletions(-) diff --git a/site/src/components/UsersTable/UsersTable.stories.tsx b/site/src/components/UsersTable/UsersTable.stories.tsx index ce019b4d3e72b..965ca671263d2 100644 --- a/site/src/components/UsersTable/UsersTable.stories.tsx +++ b/site/src/components/UsersTable/UsersTable.stories.tsx @@ -25,24 +25,7 @@ Example.args = { export const Editable = Template.bind({}) Editable.args = { - users: [ - MockUser, - MockUser2, - { - ...MockUser, - username: "John Doe", - email: "john.doe@coder.com", - roles: [], - status: "dormant", - }, - { - ...MockUser, - username: "Roger Moore", - email: "roger.moore@coder.com", - roles: [], - status: "suspended", - }, - ], + users: [MockUser, MockUser2], roles: MockAssignableSiteRoles, canEditUsers: true, canViewActivity: true, diff --git a/site/src/components/UsersTable/UsersTable.tsx b/site/src/components/UsersTable/UsersTable.tsx index 07948ecad83bf..6c991ee2a1dd0 100644 --- a/site/src/components/UsersTable/UsersTable.tsx +++ b/site/src/components/UsersTable/UsersTable.tsx @@ -26,7 +26,6 @@ export interface UsersTableProps { isLoading?: boolean onSuspendUser: (user: TypesGen.User) => void onActivateUser: (user: TypesGen.User) => void - onMarkUserDormant: (user: TypesGen.User) => void onDeleteUser: (user: TypesGen.User) => void onListWorkspaces: (user: TypesGen.User) => void onViewActivity: (user: TypesGen.User) => void @@ -48,7 +47,6 @@ export const UsersTable: FC> = ({ onListWorkspaces, onViewActivity, onActivateUser, - onMarkUserDormant, onResetUserPassword, onUpdateUserRoles, isUpdatingUserRoles, @@ -87,7 +85,6 @@ export const UsersTable: FC> = ({ canViewActivity={canViewActivity} isUpdatingUserRoles={isUpdatingUserRoles} onActivateUser={onActivateUser} - onMarkUserDormant={onMarkUserDormant} onDeleteUser={onDeleteUser} onListWorkspaces={onListWorkspaces} onViewActivity={onViewActivity} diff --git a/site/src/components/UsersTable/UsersTableBody.tsx b/site/src/components/UsersTable/UsersTableBody.tsx index bcfa2991cc1d2..24b958acfd07a 100644 --- a/site/src/components/UsersTable/UsersTableBody.tsx +++ b/site/src/components/UsersTable/UsersTableBody.tsx @@ -41,7 +41,6 @@ interface UsersTableBodyProps { onListWorkspaces: (user: TypesGen.User) => void onViewActivity: (user: TypesGen.User) => void onActivateUser: (user: TypesGen.User) => void - onMarkUserDormant: (user: TypesGen.User) => void onResetUserPassword: (user: TypesGen.User) => void onUpdateUserRoles: ( user: TypesGen.User, @@ -65,7 +64,6 @@ export const UsersTableBody: FC< onListWorkspaces, onViewActivity, onActivateUser, - onMarkUserDormant, onResetUserPassword, onUpdateUserRoles, isUpdatingUserRoles, @@ -185,26 +183,8 @@ export const UsersTableBody: FC< onClick: onSuspendUser, disabled: false, }, - { - label: t( - "markUserDormantMenuItem", - ) as React.ReactNode, - onClick: onMarkUserDormant, - disabled: user.id === actorID, - }, - ] - : user.status === "suspended" - ? [ - { - label: t( - "activateMenuItem", - ) as React.ReactNode, - onClick: onActivateUser, - disabled: false, - }, ] : [ - // User account is dormant { label: t( "activateMenuItem", @@ -212,13 +192,6 @@ export const UsersTableBody: FC< onClick: onActivateUser, disabled: false, }, - { - label: t( - "suspendMenuItem", - ) as React.ReactNode, - onClick: onSuspendUser, - disabled: false, - }, ] ).concat( { From e0a6b2f19be70349d856a30be01d46bd4a123b8a Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 1 Aug 2023 10:12:32 +0200 Subject: [PATCH 47/69] User story --- .../UsersTable/UsersTable.stories.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/site/src/components/UsersTable/UsersTable.stories.tsx b/site/src/components/UsersTable/UsersTable.stories.tsx index 965ca671263d2..ce019b4d3e72b 100644 --- a/site/src/components/UsersTable/UsersTable.stories.tsx +++ b/site/src/components/UsersTable/UsersTable.stories.tsx @@ -25,7 +25,24 @@ Example.args = { export const Editable = Template.bind({}) Editable.args = { - users: [MockUser, MockUser2], + users: [ + MockUser, + MockUser2, + { + ...MockUser, + username: "John Doe", + email: "john.doe@coder.com", + roles: [], + status: "dormant", + }, + { + ...MockUser, + username: "Roger Moore", + email: "roger.moore@coder.com", + roles: [], + status: "suspended", + }, + ], roles: MockAssignableSiteRoles, canEditUsers: true, canViewActivity: true, From 74c70a13d01ad8e6930953f0f6d54da2b120a4d4 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 1 Aug 2023 10:51:10 +0200 Subject: [PATCH 48/69] make update-golden-files --- cli/testdata/coder_scaletest_--help.golden | 16 --- .../coder_scaletest_cleanup_--help.golden | 19 --- ..._scaletest_create-workspaces_--help.golden | 114 ------------------ ..._scaletest_workspace-traffic_--help.golden | 62 ---------- .../coder_users_mark-as-dormant_--help.golden | 15 --- 5 files changed, 226 deletions(-) delete mode 100644 cli/testdata/coder_scaletest_--help.golden delete mode 100644 cli/testdata/coder_scaletest_cleanup_--help.golden delete mode 100644 cli/testdata/coder_scaletest_create-workspaces_--help.golden delete mode 100644 cli/testdata/coder_scaletest_workspace-traffic_--help.golden delete mode 100644 cli/testdata/coder_users_mark-as-dormant_--help.golden diff --git a/cli/testdata/coder_scaletest_--help.golden b/cli/testdata/coder_scaletest_--help.golden deleted file mode 100644 index 6ab343cd33377..0000000000000 --- a/cli/testdata/coder_scaletest_--help.golden +++ /dev/null @@ -1,16 +0,0 @@ -Usage: coder scaletest - -Run a scale test against the Coder API - -Subcommands - cleanup Cleanup scaletest workspaces, then cleanup scaletest - users. - create-workspaces Creates many users, then creates a workspace for each - user and waits for them finish building and fully come - online. Optionally runs a command inside each - workspace, and connects to the workspace over - WireGuard. - workspace-traffic Generate traffic to scaletest workspaces through coderd - ---- -Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_scaletest_cleanup_--help.golden b/cli/testdata/coder_scaletest_cleanup_--help.golden deleted file mode 100644 index e14e854459064..0000000000000 --- a/cli/testdata/coder_scaletest_cleanup_--help.golden +++ /dev/null @@ -1,19 +0,0 @@ -Usage: coder scaletest cleanup [flags] - -Cleanup scaletest workspaces, then cleanup scaletest users. - -The strategy flags will apply to each stage of the cleanup process. - -Options - --cleanup-concurrency int, $CODER_SCALETEST_CLEANUP_CONCURRENCY (default: 1) - Number of concurrent cleanup jobs to run. 0 means unlimited. - - --cleanup-job-timeout duration, $CODER_SCALETEST_CLEANUP_JOB_TIMEOUT (default: 5m) - Timeout per job. Jobs may take longer to complete under higher - concurrency limits. - - --cleanup-timeout duration, $CODER_SCALETEST_CLEANUP_TIMEOUT (default: 30m) - Timeout for the entire cleanup run. 0 means unlimited. - ---- -Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_scaletest_create-workspaces_--help.golden b/cli/testdata/coder_scaletest_create-workspaces_--help.golden deleted file mode 100644 index fba53f6773ef8..0000000000000 --- a/cli/testdata/coder_scaletest_create-workspaces_--help.golden +++ /dev/null @@ -1,114 +0,0 @@ -Usage: coder scaletest create-workspaces [flags] - -Creates many users, then creates a workspace for each user and waits for them -finish building and fully come online. Optionally runs a command inside each -workspace, and connects to the workspace over WireGuard. - -It is recommended that all rate limits are disabled on the server before running this scaletest. This test generates many login events which will be rate limited against the (most likely single) IP. - -Options - --cleanup-concurrency int, $CODER_SCALETEST_CLEANUP_CONCURRENCY (default: 1) - Number of concurrent cleanup jobs to run. 0 means unlimited. - - --cleanup-job-timeout duration, $CODER_SCALETEST_CLEANUP_JOB_TIMEOUT (default: 5m) - Timeout per job. Jobs may take longer to complete under higher - concurrency limits. - - --cleanup-timeout duration, $CODER_SCALETEST_CLEANUP_TIMEOUT (default: 30m) - Timeout for the entire cleanup run. 0 means unlimited. - - --concurrency int, $CODER_SCALETEST_CONCURRENCY (default: 1) - Number of concurrent jobs to run. 0 means unlimited. - - --connect-hold duration, $CODER_SCALETEST_CONNECT_HOLD (default: 30s) - How long to hold the WireGuard connection open for. - - --connect-interval duration, $CODER_SCALETEST_CONNECT_INTERVAL (default: 1s) - How long to wait between making requests to the --connect-url once the - connection is established. - - --connect-mode derp|direct, $CODER_SCALETEST_CONNECT_MODE (default: derp) - Mode to use for connecting to the workspace. - - --connect-timeout duration, $CODER_SCALETEST_CONNECT_TIMEOUT (default: 5s) - Timeout for each request to the --connect-url. - - --connect-url string, $CODER_SCALETEST_CONNECT_URL - URL to connect to inside the the workspace over WireGuard. If not - specified, no connections will be made over WireGuard. - - -c, --count int, $CODER_SCALETEST_COUNT (default: 1) - Required: Number of workspaces to create. - - --job-timeout duration, $CODER_SCALETEST_JOB_TIMEOUT (default: 5m) - Timeout per job. Jobs may take longer to complete under higher - concurrency limits. - - --no-cleanup bool, $CODER_SCALETEST_NO_CLEANUP - Do not clean up resources after the test completes. You can cleanup - manually using coder scaletest cleanup. - - --no-plan bool, $CODER_SCALETEST_NO_PLAN - Skip the dry-run step to plan the workspace creation. This step - ensures that the given parameters are valid for the given template. - - --no-wait-for-agents bool, $CODER_SCALETEST_NO_WAIT_FOR_AGENTS - Do not wait for agents to start before marking the test as succeeded. - This can be useful if you are running the test against a template that - does not start the agent quickly. - - --output string-array, $CODER_SCALETEST_OUTPUTS (default: text) - Output format specs in the format "[:]". Not specifying - a path will default to stdout. Available formats: text, json. - - --run-command string, $CODER_SCALETEST_RUN_COMMAND - Command to run inside each workspace using reconnecting-pty (i.e. web - terminal protocol). If not specified, no command will be run. - - --run-expect-output string, $CODER_SCALETEST_RUN_EXPECT_OUTPUT - Expect the command to output the given string (on a single line). If - the command does not output the given string, it will be marked as - failed. - - --run-expect-timeout bool, $CODER_SCALETEST_RUN_EXPECT_TIMEOUT - Expect the command to timeout. If the command does not finish within - the given --run-timeout, it will be marked as succeeded. If the - command finishes before the timeout, it will be marked as failed. - - --run-log-output bool, $CODER_SCALETEST_RUN_LOG_OUTPUT - Log the output of the command to the test logs. This should be left - off unless you expect small amounts of output. Large amounts of output - will cause high memory usage. - - --run-timeout duration, $CODER_SCALETEST_RUN_TIMEOUT (default: 5s) - Timeout for the command to complete. - - -t, --template string, $CODER_SCALETEST_TEMPLATE - Required: Name or ID of the template to use for workspaces. - - --timeout duration, $CODER_SCALETEST_TIMEOUT (default: 30m) - Timeout for the entire test run. 0 means unlimited. - - --trace bool, $CODER_SCALETEST_TRACE - Whether application tracing data is collected. It exports to a backend - configured by environment variables. See: - https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md. - - --trace-coder bool, $CODER_SCALETEST_TRACE_CODER - Whether opentelemetry traces are sent to Coder. We recommend keeping - this disabled unless we advise you to enable it. - - --trace-honeycomb-api-key string, $CODER_SCALETEST_TRACE_HONEYCOMB_API_KEY - Enables trace exporting to Honeycomb.io using the provided API key. - - --trace-propagate bool, $CODER_SCALETEST_TRACE_PROPAGATE - Enables trace propagation to the Coder backend, which will be used to - correlate server-side spans with client-side spans. Only enable this - if the server is configured with the exact same tracing configuration - as the client. - - --use-host-login bool, $CODER_SCALETEST_USE_HOST_LOGIN (default: false) - Use the use logged in on the host machine, instead of creating users. - ---- -Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_scaletest_workspace-traffic_--help.golden b/cli/testdata/coder_scaletest_workspace-traffic_--help.golden deleted file mode 100644 index 04f7688937516..0000000000000 --- a/cli/testdata/coder_scaletest_workspace-traffic_--help.golden +++ /dev/null @@ -1,62 +0,0 @@ -Usage: coder scaletest workspace-traffic [flags] - -Generate traffic to scaletest workspaces through coderd - -Options - --bytes-per-tick int, $CODER_SCALETEST_WORKSPACE_TRAFFIC_BYTES_PER_TICK (default: 1024) - How much traffic to generate per tick. - - --cleanup-concurrency int, $CODER_SCALETEST_CLEANUP_CONCURRENCY (default: 1) - Number of concurrent cleanup jobs to run. 0 means unlimited. - - --cleanup-job-timeout duration, $CODER_SCALETEST_CLEANUP_JOB_TIMEOUT (default: 5m) - Timeout per job. Jobs may take longer to complete under higher - concurrency limits. - - --cleanup-timeout duration, $CODER_SCALETEST_CLEANUP_TIMEOUT (default: 30m) - Timeout for the entire cleanup run. 0 means unlimited. - - --concurrency int, $CODER_SCALETEST_CONCURRENCY (default: 1) - Number of concurrent jobs to run. 0 means unlimited. - - --job-timeout duration, $CODER_SCALETEST_JOB_TIMEOUT (default: 5m) - Timeout per job. Jobs may take longer to complete under higher - concurrency limits. - - --output string-array, $CODER_SCALETEST_OUTPUTS (default: text) - Output format specs in the format "[:]". Not specifying - a path will default to stdout. Available formats: text, json. - - --scaletest-prometheus-address string, $CODER_SCALETEST_PROMETHEUS_ADDRESS (default: 0.0.0.0:21112) - Address on which to expose scaletest Prometheus metrics. - - --scaletest-prometheus-wait duration, $CODER_SCALETEST_PROMETHEUS_WAIT (default: 5s) - How long to wait before exiting in order to allow Prometheus metrics - to be scraped. - - --tick-interval duration, $CODER_SCALETEST_WORKSPACE_TRAFFIC_TICK_INTERVAL (default: 100ms) - How often to send traffic. - - --timeout duration, $CODER_SCALETEST_TIMEOUT (default: 30m) - Timeout for the entire test run. 0 means unlimited. - - --trace bool, $CODER_SCALETEST_TRACE - Whether application tracing data is collected. It exports to a backend - configured by environment variables. See: - https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md. - - --trace-coder bool, $CODER_SCALETEST_TRACE_CODER - Whether opentelemetry traces are sent to Coder. We recommend keeping - this disabled unless we advise you to enable it. - - --trace-honeycomb-api-key string, $CODER_SCALETEST_TRACE_HONEYCOMB_API_KEY - Enables trace exporting to Honeycomb.io using the provided API key. - - --trace-propagate bool, $CODER_SCALETEST_TRACE_PROPAGATE - Enables trace propagation to the Coder backend, which will be used to - correlate server-side spans with client-side spans. Only enable this - if the server is configured with the exact same tracing configuration - as the client. - ---- -Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_users_mark-as-dormant_--help.golden b/cli/testdata/coder_users_mark-as-dormant_--help.golden deleted file mode 100644 index de374222d2d7e..0000000000000 --- a/cli/testdata/coder_users_mark-as-dormant_--help.golden +++ /dev/null @@ -1,15 +0,0 @@ -Usage: coder users mark-as-dormant [flags] - -Update a user's status to 'dormant'. Dormant users are not counted in the -license plan - -Aliases: dormant - - $ coder users mark-as-dormant example_user  - -Options - -c, --column string-array (default: username,email,created_at,status) - Specify a column to filter in the table. - ---- -Run `coder --help` for a list of global options. From b010271185b47e4b418f851fbebdb4741353351c Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 1 Aug 2023 11:17:55 +0200 Subject: [PATCH 49/69] cleanup --- coderd/licenses.go | 5 ----- coderd/users.go | 33 ++++---------------------------- enterprise/coderd/coderd.go | 2 +- enterprise/coderd/coderd_test.go | 4 ++-- enterprise/coderd/licenses.go | 8 ++++++-- 5 files changed, 13 insertions(+), 39 deletions(-) delete mode 100644 coderd/licenses.go diff --git a/coderd/licenses.go b/coderd/licenses.go deleted file mode 100644 index 822124c5d5af0..0000000000000 --- a/coderd/licenses.go +++ /dev/null @@ -1,5 +0,0 @@ -package coderd - -const ( - PubsubEventLicenses = "licenses" -) diff --git a/coderd/users.go b/coderd/users.go index f5d32711fb207..017e20d408586 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -12,8 +12,6 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "cdr.dev/slog" - "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/db2sdk" @@ -659,24 +657,7 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW defer commitAudit() aReq.Old = user - if status == database.UserStatusDormant { - // There are some manual protections when marking a user as dormant to - // prevent certain situations. - switch { - case user.ID == apiKey.UserID: - // User can't mark themselves as dormant, as they are active now. - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "You cannot mark own user as dormant.", - }) - return - case slice.Contains(user.RBACRoles, rbac.RoleOwner()): - // You can't mark an owner account as dormant - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("You cannot mark a user as dormant with the %q role. You must remove the role first.", rbac.RoleOwner()), - }) - return - } - } else if status == database.UserStatusSuspended { + if status == database.UserStatusSuspended { // There are some manual protections when suspending a user to // prevent certain situations. switch { @@ -696,7 +677,7 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW } } - updatedUser, err := api.Database.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ + suspendedUser, err := api.Database.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ ID: user.ID, Status: status, UpdatedAt: database.Now(), @@ -708,7 +689,7 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW }) return } - aReq.New = updatedUser + aReq.New = suspendedUser organizations, err := userOrganizationIDs(ctx, api, user) if err != nil { @@ -719,13 +700,7 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW return } - err = api.Pubsub.Publish(PubsubEventLicenses, []byte("add")) - if err != nil { - api.Logger.Error(context.Background(), "failed to publish license add", slog.Error(err)) - // don't fail the HTTP request, since we did write it successfully to the database - } - - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(updatedUser, organizations)) + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(suspendedUser, organizations)) } } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index b3950e0a90f61..87c041b88d443 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -784,7 +784,7 @@ func (api *API) runEntitlementsLoop(ctx context.Context) { // pass } if !subscribed { - cancel, err := api.Pubsub.Subscribe(coderd.PubsubEventLicenses, func(_ context.Context, _ []byte) { + cancel, err := api.Pubsub.Subscribe(PubsubEventLicenses, func(_ context.Context, _ []byte) { // don't block. If the channel is full, drop the event, as there is a resync // scheduled already. select { diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 880ffaf68f19e..2271c79064b4f 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -8,7 +8,6 @@ import ( "github.com/google/uuid" - osscoderd "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/rbac" @@ -21,6 +20,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/audit" + "github.com/coder/coder/enterprise/coderd" "github.com/coder/coder/enterprise/coderd/coderdenttest" "github.com/coder/coder/enterprise/coderd/license" "github.com/coder/coder/testutil" @@ -122,7 +122,7 @@ func TestEntitlements(t *testing.T) { }), }) require.NoError(t, err) - err = api.Pubsub.Publish(osscoderd.PubsubEventLicenses, []byte{}) + err = api.Pubsub.Publish(coderd.PubsubEventLicenses, []byte{}) require.NoError(t, err) require.Eventually(t, func() bool { entitlements, err := client.Entitlements(context.Background()) diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index d38cde4b538e2..24085ee9a7bea 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -28,6 +28,10 @@ import ( "github.com/coder/coder/enterprise/coderd/license" ) +const ( + PubsubEventLicenses = "licenses" +) + // key20220812 is the Coder license public key with id 2022-08-12 used to validate licenses signed // by our signing infrastructure // @@ -137,7 +141,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) { }) return } - err = api.Pubsub.Publish(coderd.PubsubEventLicenses, []byte("add")) + err = api.Pubsub.Publish(PubsubEventLicenses, []byte("add")) if err != nil { api.Logger.Error(context.Background(), "failed to publish license add", slog.Error(err)) // don't fail the HTTP request, since we did write it successfully to the database @@ -252,7 +256,7 @@ func (api *API) deleteLicense(rw http.ResponseWriter, r *http.Request) { }) return } - err = api.Pubsub.Publish(coderd.PubsubEventLicenses, []byte("delete")) + err = api.Pubsub.Publish(PubsubEventLicenses, []byte("delete")) if err != nil { api.Logger.Error(context.Background(), "failed to publish license delete", slog.Error(err)) // don't fail the HTTP request, since we did write it successfully to the database From 8add5198e0d4dbf178a3b43ed79f4ec3f105ad3a Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 1 Aug 2023 12:13:36 +0200 Subject: [PATCH 50/69] bring back coder.env --- coder.env | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 coder.env diff --git a/coder.env b/coder.env new file mode 100644 index 0000000000000..0c198649e0ee6 --- /dev/null +++ b/coder.env @@ -0,0 +1,11 @@ +# Coder must be reachable from an external URL for users and workspaces to connect. +# e.g. https://coder.example.com +CODER_ACCESS_URL= + +CODER_ADDRESS= +CODER_PG_CONNECTION_URL= +CODER_TLS_CERT_FILE= +CODER_TLS_ENABLE= +CODER_TLS_KEY_FILE= + +# Run "coder server --help" for flag information. From 37f9e8f7ac7d848ef7d72b723153a7ba754c8f25 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 1 Aug 2023 12:17:23 +0200 Subject: [PATCH 51/69] fix: migration down: update dormant users to active --- coderd/database/migrations/000144_user_status_dormant.down.sql | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/database/migrations/000144_user_status_dormant.down.sql b/coderd/database/migrations/000144_user_status_dormant.down.sql index c55c8a84f3647..3463201098c37 100644 --- a/coderd/database/migrations/000144_user_status_dormant.down.sql +++ b/coderd/database/migrations/000144_user_status_dormant.down.sql @@ -1 +1,3 @@ -- It's not possible to drop enum values from enum types, so the UP has "IF NOT EXISTS" + +UPDATE users SET user_status = 'active'::user_status WHERE user_status = 'dormant'::user_status; From d9da86594476d172963a42d36a2a14c655eee3a5 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 1 Aug 2023 12:23:40 +0200 Subject: [PATCH 52/69] fix --- coderd/userauth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/userauth.go b/coderd/userauth.go index 855aa89e5440b..9b6ba7992bad5 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -330,7 +330,7 @@ func (api *API) loginRequest(ctx context.Context, rw http.ResponseWriter, req co if err != nil { logger.Error(ctx, "unable to update user status to active", slog.Error(err)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error.", + Message: "Internal error occurred. Try again later, or contact an admin for assistance.", }) return user, database.GetAuthorizationUserRolesRow{}, false } From 2749c910e9b099d0bae3959d40dc37c51a38c7a3 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 1 Aug 2023 12:37:08 +0200 Subject: [PATCH 53/69] Fix: migration --- coderd/database/migrations/000144_user_status_dormant.down.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/migrations/000144_user_status_dormant.down.sql b/coderd/database/migrations/000144_user_status_dormant.down.sql index 3463201098c37..55504e0938064 100644 --- a/coderd/database/migrations/000144_user_status_dormant.down.sql +++ b/coderd/database/migrations/000144_user_status_dormant.down.sql @@ -1,3 +1,3 @@ -- It's not possible to drop enum values from enum types, so the UP has "IF NOT EXISTS" -UPDATE users SET user_status = 'active'::user_status WHERE user_status = 'dormant'::user_status; +UPDATE users SET status = 'active'::user_status WHERE status = 'dormant'::user_status; From cdbc50e465e7922567a85cb6d993380558c3cc2d Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 1 Aug 2023 17:24:56 +0200 Subject: [PATCH 54/69] Implement job --- cli/server.go | 4 ++ coderd/database/dbauthz/dbauthz.go | 7 ++++ coderd/database/dbfake/dbfake.go | 22 ++++++++++ coderd/database/dbmetrics/dbmetrics.go | 7 ++++ coderd/database/dbmock/dbmock.go | 15 +++++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 40 ++++++++++++++++++ coderd/database/queries/users.sql | 11 +++++ coderd/dormancy/dormantusersjob.go | 57 ++++++++++++++++++++++++++ 9 files changed, 164 insertions(+) create mode 100644 coderd/dormancy/dormantusersjob.go diff --git a/cli/server.go b/cli/server.go index c55b58831c0a5..170b7c5eb9f00 100644 --- a/cli/server.go +++ b/cli/server.go @@ -70,6 +70,7 @@ import ( "github.com/coder/coder/coderd/database/migrations" "github.com/coder/coder/coderd/database/pubsub" "github.com/coder/coder/coderd/devtunnel" + "github.com/coder/coder/coderd/dormancy" "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" @@ -812,6 +813,9 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. options.SwaggerEndpoint = cfg.Swagger.Enable.Value() } + closeCheckInactiveUsersFunc := dormancy.CheckInactiveUsers(ctx, logger, options.Database) + defer closeCheckInactiveUsersFunc() + // We use a separate coderAPICloser so the Enterprise API // can have it's own close functions. This is cleaner // than abstracting the Coder API itself. diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f3ea758dce9f9..d0f79ecc52ff2 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2104,6 +2104,13 @@ func (q *querier) UpdateGroupByID(ctx context.Context, arg database.UpdateGroupB return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateGroupByID)(ctx, arg) } +func (q *querier) UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfter time.Time) ([]database.UpdateInactiveUsersToDormantRow, error) { + if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.UpdateInactiveUsersToDormant(ctx, lastSeenAfter) +} + func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) { // Authorized fetch will check that the actor has read access to the org member since the org member is returned. member, err := q.GetOrganizationMemberByUserID(ctx, database.GetOrganizationMemberByUserIDParams{ diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index f39de4fd11a5b..27b9f61dd20f2 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -4337,6 +4337,28 @@ func (q *FakeQuerier) UpdateGroupByID(_ context.Context, arg database.UpdateGrou return database.Group{}, sql.ErrNoRows } +func (q *FakeQuerier) UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfter time.Time) ([]database.UpdateInactiveUsersToDormantRow, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + var updated []database.UpdateInactiveUsersToDormantRow + for index, user := range q.users { + if user.Status == database.UserStatusActive && user.LastSeenAt.Before(lastSeenAfter) { + q.users[index].Status = database.UserStatusDormant + updated = append(updated, database.UpdateInactiveUsersToDormantRow{ + ID: user.ID, + Email: user.Email, + LastSeenAt: user.LastSeenAt, + }) + } + } + + if len(updated) == 0 { + return nil, sql.ErrNoRows + } + return updated, nil +} + func (q *FakeQuerier) UpdateMemberRoles(_ context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) { if err := validateDatabaseType(arg); err != nil { return database.OrganizationMember{}, err diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 95dde653ca547..78e124fe4919c 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1313,6 +1313,13 @@ func (m metricsStore) UpdateGroupByID(ctx context.Context, arg database.UpdateGr return group, err } +func (m metricsStore) UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfter time.Time) ([]database.UpdateInactiveUsersToDormantRow, error) { + start := time.Now() + r0, r1 := m.s.UpdateInactiveUsersToDormant(ctx, lastSeenAfter) + m.queryLatencies.WithLabelValues("UpdateInactiveUsersToDormant").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) { start := time.Now() member, err := m.s.UpdateMemberRoles(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index f6ee26a15f817..c3bbdbf69752a 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2775,6 +2775,21 @@ func (mr *MockStoreMockRecorder) UpdateGroupByID(arg0, arg1 interface{}) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGroupByID", reflect.TypeOf((*MockStore)(nil).UpdateGroupByID), arg0, arg1) } +// UpdateInactiveUsersToDormant mocks base method. +func (m *MockStore) UpdateInactiveUsersToDormant(arg0 context.Context, arg1 time.Time) ([]database.UpdateInactiveUsersToDormantRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateInactiveUsersToDormant", arg0, arg1) + ret0, _ := ret[0].([]database.UpdateInactiveUsersToDormantRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateInactiveUsersToDormant indicates an expected call of UpdateInactiveUsersToDormant. +func (mr *MockStoreMockRecorder) UpdateInactiveUsersToDormant(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateInactiveUsersToDormant", reflect.TypeOf((*MockStore)(nil).UpdateInactiveUsersToDormant), arg0, arg1) +} + // UpdateMemberRoles mocks base method. func (m *MockStore) UpdateMemberRoles(arg0 context.Context, arg1 database.UpdateMemberRolesParams) (database.OrganizationMember, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index be33b00bfc51b..3c53dd8e2514c 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -236,6 +236,7 @@ type sqlcQuerier interface { UpdateGitAuthLink(ctx context.Context, arg UpdateGitAuthLinkParams) (GitAuthLink, error) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) + UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfter time.Time) ([]UpdateInactiveUsersToDormantRow, error) UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error) UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error UpdateProvisionerJobWithCancelByID(ctx context.Context, arg UpdateProvisionerJobWithCancelByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 87d86e9621fbb..005b7d3a834d9 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5708,6 +5708,46 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User return i, err } +const updateInactiveUsersToDormant = `-- name: UpdateInactiveUsersToDormant :many +UPDATE + users +SET + user_status = 'dormant'::user_status +WHERE + last_seen_at < $1 :: timestamp + AND user_status = 'active'::user_status +RETURNING id, email, last_seen_at +` + +type UpdateInactiveUsersToDormantRow struct { + ID uuid.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` +} + +func (q *sqlQuerier) UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfter time.Time) ([]UpdateInactiveUsersToDormantRow, error) { + rows, err := q.db.QueryContext(ctx, updateInactiveUsersToDormant, lastSeenAfter) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UpdateInactiveUsersToDormantRow + for rows.Next() { + var i UpdateInactiveUsersToDormantRow + if err := rows.Scan(&i.ID, &i.Email, &i.LastSeenAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateUserDeletedByID = `-- name: UpdateUserDeletedByID :exec UPDATE users diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index f44994a3987cc..4d5ebc4337c60 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -250,3 +250,14 @@ SET WHERE id = $1 RETURNING *; + + +-- name: UpdateInactiveUsersToDormant :many +UPDATE + users +SET + user_status = 'dormant'::user_status +WHERE + last_seen_at < @last_seen_after :: timestamp + AND user_status = 'active'::user_status +RETURNING id, email, last_seen_at; diff --git a/coderd/dormancy/dormantusersjob.go b/coderd/dormancy/dormantusersjob.go new file mode 100644 index 0000000000000..ec9107ea4f9bb --- /dev/null +++ b/coderd/dormancy/dormantusersjob.go @@ -0,0 +1,57 @@ +package dormancy + +import ( + "context" + "database/sql" + "time" + + "golang.org/x/xerrors" + + "cdr.dev/slog" + + "github.com/coder/coder/coderd/database" +) + +const ( + checkDuration = 5 * time.Minute + dormancyPeriod = 90 * 24 * time.Hour +) + +func CheckInactiveUsers(ctx context.Context, logger slog.Logger, db database.Store) func() { + logger = logger.Named("dormancy") + + ctx, cancelFunc := context.WithCancel(ctx) + done := make(chan struct{}) + ticker := time.NewTicker(checkDuration) + go func() { + defer close(done) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + + lastSeenAfter := database.Now().Add(-dormancyPeriod) + logger.Debug(ctx, "check inactive user accounts", slog.F("dormancy_period", dormancyPeriod), slog.F("last_seen_after", lastSeenAfter)) + + updatedUsers, err := db.UpdateInactiveUsersToDormant(ctx, lastSeenAfter) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + logger.Error(ctx, "can't mark inactive users as dormant", slog.Error(err)) + goto done + } + + for _, u := range updatedUsers { + logger.Debug(ctx, "account has been marked as dormant", slog.F("email", u.Email), slog.F("last_seen_at", u.LastSeenAt)) + } + done: + logger.Debug(ctx, "checking user accounts is done", slog.F("num_dormant_accounts", len(updatedUsers))) + } + }() + + return func() { + cancelFunc() + <-done + } +} From 916d807ed0b689c191b4ffe2f37809ee8cad3f4a Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 1 Aug 2023 17:26:20 +0200 Subject: [PATCH 55/69] fix --- coderd/database/dbfake/dbfake.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 27b9f61dd20f2..708bbae243ea6 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -4337,7 +4337,7 @@ func (q *FakeQuerier) UpdateGroupByID(_ context.Context, arg database.UpdateGrou return database.Group{}, sql.ErrNoRows } -func (q *FakeQuerier) UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfter time.Time) ([]database.UpdateInactiveUsersToDormantRow, error) { +func (q *FakeQuerier) UpdateInactiveUsersToDormant(_ context.Context, lastSeenAfter time.Time) ([]database.UpdateInactiveUsersToDormantRow, error) { q.mutex.Lock() defer q.mutex.Unlock() From 32723cf5bf72705c5eefb0aca811e15cffef9ca2 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 2 Aug 2023 09:38:56 +0200 Subject: [PATCH 56/69] WIP --- coderd/dormancy/dormantusersjob_test.go | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 coderd/dormancy/dormantusersjob_test.go diff --git a/coderd/dormancy/dormantusersjob_test.go b/coderd/dormancy/dormantusersjob_test.go new file mode 100644 index 0000000000000..f9791ff247454 --- /dev/null +++ b/coderd/dormancy/dormantusersjob_test.go @@ -0,0 +1,7 @@ +package dormancy_test + +import "testing" + +func TestCheckInactiveUsers(t *testing.T) { + +} From e145b74eab5c0ff20c0fee92fb7f6b914bd0441f Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 2 Aug 2023 11:57:41 +0200 Subject: [PATCH 57/69] Write unit test for job --- coderd/dormancy/dormantusersjob.go | 10 ++- coderd/dormancy/dormantusersjob_test.go | 87 ++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/coderd/dormancy/dormantusersjob.go b/coderd/dormancy/dormantusersjob.go index ec9107ea4f9bb..98f2eb49755e3 100644 --- a/coderd/dormancy/dormantusersjob.go +++ b/coderd/dormancy/dormantusersjob.go @@ -13,16 +13,20 @@ import ( ) const ( - checkDuration = 5 * time.Minute - dormancyPeriod = 90 * 24 * time.Hour + jobInterval = 5 * time.Minute + accountDormancyPeriod = 90 * 24 * time.Hour ) func CheckInactiveUsers(ctx context.Context, logger slog.Logger, db database.Store) func() { + return CheckInactiveUsersWithOptions(ctx, logger, db, jobInterval, accountDormancyPeriod) +} + +func CheckInactiveUsersWithOptions(ctx context.Context, logger slog.Logger, db database.Store, checkInterval, dormancyPeriod time.Duration) func() { logger = logger.Named("dormancy") ctx, cancelFunc := context.WithCancel(ctx) done := make(chan struct{}) - ticker := time.NewTicker(checkDuration) + ticker := time.NewTicker(checkInterval) go func() { defer close(done) defer ticker.Stop() diff --git a/coderd/dormancy/dormantusersjob_test.go b/coderd/dormancy/dormantusersjob_test.go index f9791ff247454..090fe18564fec 100644 --- a/coderd/dormancy/dormantusersjob_test.go +++ b/coderd/dormancy/dormantusersjob_test.go @@ -1,7 +1,92 @@ package dormancy_test -import "testing" +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/moby/moby/pkg/namesgenerator" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/sloggers/slogtest" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbfake" + "github.com/coder/coder/coderd/dormancy" + "github.com/coder/coder/testutil" +) func TestCheckInactiveUsers(t *testing.T) { + t.Parallel() + + // Predefine job settings + interval := time.Millisecond + dormancyPeriod := 90 * 24 * time.Hour + + // Add some dormant accounts + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + db := dbfake.New() + + ctx, cancelFunc := context.WithCancel(context.Background()) + t.Cleanup(cancelFunc) + + inactiveUser1 := setupUser(ctx, t, db, "dormant-user-1@coder.com", time.Now().Add(-dormancyPeriod).Add(-time.Minute)) + inactiveUser2 := setupUser(ctx, t, db, "dormant-user-2@coder.com", time.Now().Add(-dormancyPeriod).Add(-time.Hour)) + inactiveUser3 := setupUser(ctx, t, db, "dormant-user-3@coder.com", time.Now().Add(-dormancyPeriod).Add(-6*time.Hour)) + + activeUser1 := setupUser(ctx, t, db, "active-user-1@coder.com", time.Now().Add(-dormancyPeriod).Add(time.Minute)) + activeUser2 := setupUser(ctx, t, db, "active-user-2@coder.com", time.Now().Add(-dormancyPeriod).Add(time.Hour)) + activeUser3 := setupUser(ctx, t, db, "active-user-3@coder.com", time.Now().Add(-dormancyPeriod).Add(6*time.Hour)) + + // Run the periodic job + closeFunc := dormancy.CheckInactiveUsersWithOptions(ctx, logger, db, interval, dormancyPeriod) + t.Cleanup(closeFunc) + + var rows []database.GetUsersRow + var err error + require.Eventually(t, func() bool { + rows, err = db.GetUsers(ctx, database.GetUsersParams{}) + if err != nil { + return false + } + + var c int + for _, row := range rows { + if row.Status == database.UserStatusDormant { + c++ + } + } + // 6 users in total, 3 dormant + return len(rows) == 6 && c == 3 + }, testutil.WaitShort, testutil.IntervalMedium) + + allUsers := database.ConvertUserRows(rows) + + // Verify user status + expectedUsers := []database.User{ + asDormant(inactiveUser1), + asDormant(inactiveUser2), + asDormant(inactiveUser3), + activeUser1, + activeUser2, + activeUser3, + } + require.ElementsMatch(t, allUsers, expectedUsers) +} + +func setupUser(ctx context.Context, t *testing.T, db database.Store, email string, lastSeenAt time.Time) database.User { + user, err := db.InsertUser(ctx, database.InsertUserParams{ID: uuid.New(), LoginType: database.LoginTypePassword, Username: namesgenerator.GetRandomName(8), Email: email}) + require.NoError(t, err) + // At the beginning of the test all users are marked as active + user, err = db.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ID: user.ID, Status: database.UserStatusActive}) + require.NoError(t, err) + user, err = db.UpdateUserLastSeenAt(ctx, database.UpdateUserLastSeenAtParams{ID: user.ID, LastSeenAt: lastSeenAt}) + require.NoError(t, err) + return user +} +func asDormant(user database.User) database.User { + user.Status = database.UserStatusDormant + return user } From 187e866986d7fd31e44f1716a60c6f48dc5d2a69 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 2 Aug 2023 12:38:16 +0200 Subject: [PATCH 58/69] Fix --- coderd/database/queries.sql.go | 4 ++-- coderd/database/queries/users.sql | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 005b7d3a834d9..9367cef65f76f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5712,10 +5712,10 @@ const updateInactiveUsersToDormant = `-- name: UpdateInactiveUsersToDormant :man UPDATE users SET - user_status = 'dormant'::user_status + status = 'dormant'::user_status WHERE last_seen_at < $1 :: timestamp - AND user_status = 'active'::user_status + AND status = 'active'::user_status RETURNING id, email, last_seen_at ` diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 4d5ebc4337c60..c7232a0865429 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -256,8 +256,8 @@ RETURNING *; UPDATE users SET - user_status = 'dormant'::user_status + status = 'dormant'::user_status WHERE last_seen_at < @last_seen_after :: timestamp - AND user_status = 'active'::user_status + AND status = 'active'::user_status RETURNING id, email, last_seen_at; From 82b791acfc9b1294348a82ff8d4ab9674c107d6e Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 2 Aug 2023 12:40:05 +0200 Subject: [PATCH 59/69] info --- coderd/dormancy/dormantusersjob.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/dormancy/dormantusersjob.go b/coderd/dormancy/dormantusersjob.go index 98f2eb49755e3..e18c38e3bea9c 100644 --- a/coderd/dormancy/dormantusersjob.go +++ b/coderd/dormancy/dormantusersjob.go @@ -47,7 +47,7 @@ func CheckInactiveUsersWithOptions(ctx context.Context, logger slog.Logger, db d } for _, u := range updatedUsers { - logger.Debug(ctx, "account has been marked as dormant", slog.F("email", u.Email), slog.F("last_seen_at", u.LastSeenAt)) + logger.Info(ctx, "account has been marked as dormant", slog.F("email", u.Email), slog.F("last_seen_at", u.LastSeenAt)) } done: logger.Debug(ctx, "checking user accounts is done", slog.F("num_dormant_accounts", len(updatedUsers))) From 4545cb6a1017e5dcf5ea05dc071a9eb51eaecb63 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 2 Aug 2023 12:55:05 +0200 Subject: [PATCH 60/69] Address PR feedback --- coderd/dormancy/dormantusersjob.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/dormancy/dormantusersjob.go b/coderd/dormancy/dormantusersjob.go index e18c38e3bea9c..a09802fe84bd2 100644 --- a/coderd/dormancy/dormantusersjob.go +++ b/coderd/dormancy/dormantusersjob.go @@ -37,20 +37,20 @@ func CheckInactiveUsersWithOptions(ctx context.Context, logger slog.Logger, db d case <-ticker.C: } + startTime := time.Now() lastSeenAfter := database.Now().Add(-dormancyPeriod) logger.Debug(ctx, "check inactive user accounts", slog.F("dormancy_period", dormancyPeriod), slog.F("last_seen_after", lastSeenAfter)) updatedUsers, err := db.UpdateInactiveUsersToDormant(ctx, lastSeenAfter) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { logger.Error(ctx, "can't mark inactive users as dormant", slog.Error(err)) - goto done + continue } for _, u := range updatedUsers { logger.Info(ctx, "account has been marked as dormant", slog.F("email", u.Email), slog.F("last_seen_at", u.LastSeenAt)) } - done: - logger.Debug(ctx, "checking user accounts is done", slog.F("num_dormant_accounts", len(updatedUsers))) + logger.Debug(ctx, "checking user accounts is done", slog.F("num_dormant_accounts", len(updatedUsers)), slog.F("execution_time", time.Since(startTime))) } }() From 85d6faeeea9eab94f890b346a0b33b22b894b3f3 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 2 Aug 2023 12:56:29 +0200 Subject: [PATCH 61/69] t.Helper --- coderd/dormancy/dormantusersjob_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/dormancy/dormantusersjob_test.go b/coderd/dormancy/dormantusersjob_test.go index 090fe18564fec..3de2fca79c79b 100644 --- a/coderd/dormancy/dormantusersjob_test.go +++ b/coderd/dormancy/dormantusersjob_test.go @@ -76,6 +76,8 @@ func TestCheckInactiveUsers(t *testing.T) { } func setupUser(ctx context.Context, t *testing.T, db database.Store, email string, lastSeenAt time.Time) database.User { + t.Helper() + user, err := db.InsertUser(ctx, database.InsertUserParams{ID: uuid.New(), LoginType: database.LoginTypePassword, Username: namesgenerator.GetRandomName(8), Email: email}) require.NoError(t, err) // At the beginning of the test all users are marked as active From d71ba7fef517f3bfe43d5d1810233faf193373c3 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 2 Aug 2023 14:38:13 +0200 Subject: [PATCH 62/69] Address PR comments --- coderd/dormancy/dormantusersjob.go | 6 ++++- coderd/dormancy/dormantusersjob_test.go | 33 ++++++++++++++++--------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/coderd/dormancy/dormantusersjob.go b/coderd/dormancy/dormantusersjob.go index a09802fe84bd2..018722104e6f0 100644 --- a/coderd/dormancy/dormantusersjob.go +++ b/coderd/dormancy/dormantusersjob.go @@ -13,14 +13,18 @@ import ( ) const ( - jobInterval = 5 * time.Minute + jobInterval = 15 * time.Minute accountDormancyPeriod = 90 * 24 * time.Hour ) +// CheckInactiveUsers function updates status of inactive users from active to dormant +// using default parameters. func CheckInactiveUsers(ctx context.Context, logger slog.Logger, db database.Store) func() { return CheckInactiveUsersWithOptions(ctx, logger, db, jobInterval, accountDormancyPeriod) } +// CheckInactiveUsersWithOptions function updates status of inactive users from active to dormant +// using provided parameters. func CheckInactiveUsersWithOptions(ctx context.Context, logger slog.Logger, db database.Store, checkInterval, dormancyPeriod time.Duration) func() { logger = logger.Named("dormancy") diff --git a/coderd/dormancy/dormantusersjob_test.go b/coderd/dormancy/dormantusersjob_test.go index 3de2fca79c79b..12fbdc24b607e 100644 --- a/coderd/dormancy/dormantusersjob_test.go +++ b/coderd/dormancy/dormantusersjob_test.go @@ -31,13 +31,17 @@ func TestCheckInactiveUsers(t *testing.T) { ctx, cancelFunc := context.WithCancel(context.Background()) t.Cleanup(cancelFunc) - inactiveUser1 := setupUser(ctx, t, db, "dormant-user-1@coder.com", time.Now().Add(-dormancyPeriod).Add(-time.Minute)) - inactiveUser2 := setupUser(ctx, t, db, "dormant-user-2@coder.com", time.Now().Add(-dormancyPeriod).Add(-time.Hour)) - inactiveUser3 := setupUser(ctx, t, db, "dormant-user-3@coder.com", time.Now().Add(-dormancyPeriod).Add(-6*time.Hour)) + inactiveUser1 := setupUser(ctx, t, db, "dormant-user-1@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(-time.Minute)) + inactiveUser2 := setupUser(ctx, t, db, "dormant-user-2@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(-time.Hour)) + inactiveUser3 := setupUser(ctx, t, db, "dormant-user-3@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(-6*time.Hour)) - activeUser1 := setupUser(ctx, t, db, "active-user-1@coder.com", time.Now().Add(-dormancyPeriod).Add(time.Minute)) - activeUser2 := setupUser(ctx, t, db, "active-user-2@coder.com", time.Now().Add(-dormancyPeriod).Add(time.Hour)) - activeUser3 := setupUser(ctx, t, db, "active-user-3@coder.com", time.Now().Add(-dormancyPeriod).Add(6*time.Hour)) + activeUser1 := setupUser(ctx, t, db, "active-user-1@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(time.Minute)) + activeUser2 := setupUser(ctx, t, db, "active-user-2@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(time.Hour)) + activeUser3 := setupUser(ctx, t, db, "active-user-3@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(6*time.Hour)) + + suspendedUser1 := setupUser(ctx, t, db, "suspended-user-1@coder.com", database.UserStatusSuspended, time.Now().Add(-dormancyPeriod).Add(-time.Minute)) + suspendedUser2 := setupUser(ctx, t, db, "suspended-user-2@coder.com", database.UserStatusSuspended, time.Now().Add(-dormancyPeriod).Add(-time.Hour)) + suspendedUser3 := setupUser(ctx, t, db, "suspended-user-3@coder.com", database.UserStatusSuspended, time.Now().Add(-dormancyPeriod).Add(-6*time.Hour)) // Run the periodic job closeFunc := dormancy.CheckInactiveUsersWithOptions(ctx, logger, db, interval, dormancyPeriod) @@ -51,14 +55,16 @@ func TestCheckInactiveUsers(t *testing.T) { return false } - var c int + var dormant, suspended int for _, row := range rows { if row.Status == database.UserStatusDormant { - c++ + dormant++ + } else if row.Status == database.UserStatusSuspended { + suspended++ } } - // 6 users in total, 3 dormant - return len(rows) == 6 && c == 3 + // 6 users in total, 3 dormant, 3 suspended + return len(rows) == 9 && dormant == 3 && suspended == 3 }, testutil.WaitShort, testutil.IntervalMedium) allUsers := database.ConvertUserRows(rows) @@ -71,17 +77,20 @@ func TestCheckInactiveUsers(t *testing.T) { activeUser1, activeUser2, activeUser3, + suspendedUser1, + suspendedUser2, + suspendedUser3, } require.ElementsMatch(t, allUsers, expectedUsers) } -func setupUser(ctx context.Context, t *testing.T, db database.Store, email string, lastSeenAt time.Time) database.User { +func setupUser(ctx context.Context, t *testing.T, db database.Store, email string, status database.UserStatus, lastSeenAt time.Time) database.User { t.Helper() user, err := db.InsertUser(ctx, database.InsertUserParams{ID: uuid.New(), LoginType: database.LoginTypePassword, Username: namesgenerator.GetRandomName(8), Email: email}) require.NoError(t, err) // At the beginning of the test all users are marked as active - user, err = db.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ID: user.ID, Status: database.UserStatusActive}) + user, err = db.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ID: user.ID, Status: status}) require.NoError(t, err) user, err = db.UpdateUserLastSeenAt(ctx, database.UpdateUserLastSeenAtParams{ID: user.ID, LastSeenAt: lastSeenAt}) require.NoError(t, err) From f7ab39d4280aba635eb1273be35d96c1047f279a Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 2 Aug 2023 15:05:14 +0200 Subject: [PATCH 63/69] Fix: populate UpdatedAt --- coderd/database/dbauthz/dbauthz.go | 2 +- coderd/database/dbfake/dbfake.go | 5 +++-- coderd/database/dbmetrics/dbmetrics.go | 2 +- coderd/database/dbmock/dbmock.go | 2 +- coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 14 ++++++++++---- coderd/database/queries/users.sql | 3 ++- coderd/dormancy/dormantusersjob.go | 9 +++++++-- coderd/dormancy/dormantusersjob_test.go | 9 ++++++++- 9 files changed, 34 insertions(+), 14 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 4cc2684f82946..56e7c3d273655 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2099,7 +2099,7 @@ func (q *querier) UpdateGroupByID(ctx context.Context, arg database.UpdateGroupB return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateGroupByID)(ctx, arg) } -func (q *querier) UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfter time.Time) ([]database.UpdateInactiveUsersToDormantRow, error) { +func (q *querier) UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfter database.UpdateInactiveUsersToDormantParams) ([]database.UpdateInactiveUsersToDormantRow, error) { if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceSystem); err != nil { return nil, err } diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 708bbae243ea6..5766e7b84107d 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -4337,14 +4337,15 @@ func (q *FakeQuerier) UpdateGroupByID(_ context.Context, arg database.UpdateGrou return database.Group{}, sql.ErrNoRows } -func (q *FakeQuerier) UpdateInactiveUsersToDormant(_ context.Context, lastSeenAfter time.Time) ([]database.UpdateInactiveUsersToDormantRow, error) { +func (q *FakeQuerier) UpdateInactiveUsersToDormant(_ context.Context, params database.UpdateInactiveUsersToDormantParams) ([]database.UpdateInactiveUsersToDormantRow, error) { q.mutex.Lock() defer q.mutex.Unlock() var updated []database.UpdateInactiveUsersToDormantRow for index, user := range q.users { - if user.Status == database.UserStatusActive && user.LastSeenAt.Before(lastSeenAfter) { + if user.Status == database.UserStatusActive && user.LastSeenAt.Before(params.LastSeenAfter) { q.users[index].Status = database.UserStatusDormant + q.users[index].UpdatedAt = params.UpdatedAt updated = append(updated, database.UpdateInactiveUsersToDormantRow{ ID: user.ID, Email: user.Email, diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 78e124fe4919c..d9e8ef6d3c2ce 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1313,7 +1313,7 @@ func (m metricsStore) UpdateGroupByID(ctx context.Context, arg database.UpdateGr return group, err } -func (m metricsStore) UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfter time.Time) ([]database.UpdateInactiveUsersToDormantRow, error) { +func (m metricsStore) UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfter database.UpdateInactiveUsersToDormantParams) ([]database.UpdateInactiveUsersToDormantRow, error) { start := time.Now() r0, r1 := m.s.UpdateInactiveUsersToDormant(ctx, lastSeenAfter) m.queryLatencies.WithLabelValues("UpdateInactiveUsersToDormant").Observe(time.Since(start).Seconds()) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index c3bbdbf69752a..bf1732568fd42 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2776,7 +2776,7 @@ func (mr *MockStoreMockRecorder) UpdateGroupByID(arg0, arg1 interface{}) *gomock } // UpdateInactiveUsersToDormant mocks base method. -func (m *MockStore) UpdateInactiveUsersToDormant(arg0 context.Context, arg1 time.Time) ([]database.UpdateInactiveUsersToDormantRow, error) { +func (m *MockStore) UpdateInactiveUsersToDormant(arg0 context.Context, arg1 database.UpdateInactiveUsersToDormantParams) ([]database.UpdateInactiveUsersToDormantRow, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateInactiveUsersToDormant", arg0, arg1) ret0, _ := ret[0].([]database.UpdateInactiveUsersToDormantRow) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 3c53dd8e2514c..c53c879dba5e7 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -236,7 +236,7 @@ type sqlcQuerier interface { UpdateGitAuthLink(ctx context.Context, arg UpdateGitAuthLinkParams) (GitAuthLink, error) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) - UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfter time.Time) ([]UpdateInactiveUsersToDormantRow, error) + UpdateInactiveUsersToDormant(ctx context.Context, arg UpdateInactiveUsersToDormantParams) ([]UpdateInactiveUsersToDormantRow, error) UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error) UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error UpdateProvisionerJobWithCancelByID(ctx context.Context, arg UpdateProvisionerJobWithCancelByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 9367cef65f76f..19c3b087eace0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5712,21 +5712,27 @@ const updateInactiveUsersToDormant = `-- name: UpdateInactiveUsersToDormant :man UPDATE users SET - status = 'dormant'::user_status + status = 'dormant'::user_status, + updated_at = $1 WHERE - last_seen_at < $1 :: timestamp + last_seen_at < $2 :: timestamp AND status = 'active'::user_status RETURNING id, email, last_seen_at ` +type UpdateInactiveUsersToDormantParams struct { + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + LastSeenAfter time.Time `db:"last_seen_after" json:"last_seen_after"` +} + type UpdateInactiveUsersToDormantRow struct { ID uuid.UUID `db:"id" json:"id"` Email string `db:"email" json:"email"` LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` } -func (q *sqlQuerier) UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfter time.Time) ([]UpdateInactiveUsersToDormantRow, error) { - rows, err := q.db.QueryContext(ctx, updateInactiveUsersToDormant, lastSeenAfter) +func (q *sqlQuerier) UpdateInactiveUsersToDormant(ctx context.Context, arg UpdateInactiveUsersToDormantParams) ([]UpdateInactiveUsersToDormantRow, error) { + rows, err := q.db.QueryContext(ctx, updateInactiveUsersToDormant, arg.UpdatedAt, arg.LastSeenAfter) if err != nil { return nil, err } diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index c7232a0865429..8560bf0abf696 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -256,7 +256,8 @@ RETURNING *; UPDATE users SET - status = 'dormant'::user_status + status = 'dormant'::user_status, + updated_at = @updated_at WHERE last_seen_at < @last_seen_after :: timestamp AND status = 'active'::user_status diff --git a/coderd/dormancy/dormantusersjob.go b/coderd/dormancy/dormantusersjob.go index 018722104e6f0..cb73004c9e0ac 100644 --- a/coderd/dormancy/dormantusersjob.go +++ b/coderd/dormancy/dormantusersjob.go @@ -13,7 +13,9 @@ import ( ) const ( - jobInterval = 15 * time.Minute + // Time interval between consecutive job runs + jobInterval = 10 * time.Minute + // User accounts inactive for `accountDormancyPeriod` will be marked as dormant accountDormancyPeriod = 90 * 24 * time.Hour ) @@ -45,7 +47,10 @@ func CheckInactiveUsersWithOptions(ctx context.Context, logger slog.Logger, db d lastSeenAfter := database.Now().Add(-dormancyPeriod) logger.Debug(ctx, "check inactive user accounts", slog.F("dormancy_period", dormancyPeriod), slog.F("last_seen_after", lastSeenAfter)) - updatedUsers, err := db.UpdateInactiveUsersToDormant(ctx, lastSeenAfter) + updatedUsers, err := db.UpdateInactiveUsersToDormant(ctx, database.UpdateInactiveUsersToDormantParams{ + LastSeenAfter: lastSeenAfter, + UpdatedAt: database.Now(), + }) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { logger.Error(ctx, "can't mark inactive users as dormant", slog.Error(err)) continue diff --git a/coderd/dormancy/dormantusersjob_test.go b/coderd/dormancy/dormantusersjob_test.go index 12fbdc24b607e..73224da872c6e 100644 --- a/coderd/dormancy/dormantusersjob_test.go +++ b/coderd/dormancy/dormantusersjob_test.go @@ -67,7 +67,7 @@ func TestCheckInactiveUsers(t *testing.T) { return len(rows) == 9 && dormant == 3 && suspended == 3 }, testutil.WaitShort, testutil.IntervalMedium) - allUsers := database.ConvertUserRows(rows) + allUsers := ignoreUpdatedAt(database.ConvertUserRows(rows)) // Verify user status expectedUsers := []database.User{ @@ -101,3 +101,10 @@ func asDormant(user database.User) database.User { user.Status = database.UserStatusDormant return user } + +func ignoreUpdatedAt(rows []database.User) []database.User { + for i := range rows { + rows[i].UpdatedAt = time.Time{} + } + return rows +} From 76d24c7565761b1837ddb6c7e822e599621827a5 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 2 Aug 2023 15:13:40 +0200 Subject: [PATCH 64/69] Update interval --- coderd/dormancy/dormantusersjob.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/dormancy/dormantusersjob.go b/coderd/dormancy/dormantusersjob.go index cb73004c9e0ac..be1e0cf0fe61b 100644 --- a/coderd/dormancy/dormantusersjob.go +++ b/coderd/dormancy/dormantusersjob.go @@ -14,7 +14,7 @@ import ( const ( // Time interval between consecutive job runs - jobInterval = 10 * time.Minute + jobInterval = 15 * time.Minute // User accounts inactive for `accountDormancyPeriod` will be marked as dormant accountDormancyPeriod = 90 * 24 * time.Hour ) From f6890dbcb77b9dc374342dc645ffc4e5f68e7c95 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 2 Aug 2023 15:45:26 +0200 Subject: [PATCH 65/69] or dormant --- site/src/components/UsersTable/UsersTableBody.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/UsersTable/UsersTableBody.tsx b/site/src/components/UsersTable/UsersTableBody.tsx index 24b958acfd07a..87fc5725a2de7 100644 --- a/site/src/components/UsersTable/UsersTableBody.tsx +++ b/site/src/components/UsersTable/UsersTableBody.tsx @@ -174,7 +174,7 @@ export const UsersTableBody: FC< data={user} menuItems={ // Return either suspend or activate depending on status - (user.status === "active" + (user.status === "active" || user.status === "dormant" ? [ { label: t( From d34e06cd9f2cdebb7a2e1121a70d03fe079214c4 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 2 Aug 2023 15:58:54 +0200 Subject: [PATCH 66/69] update docs --- docs/admin/users.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/admin/users.md b/docs/admin/users.md index 4f6c6d2d951ea..6850fa8d371e7 100644 --- a/docs/admin/users.md +++ b/docs/admin/users.md @@ -41,8 +41,7 @@ A user account is set to _dormant_ status when the user has not yet logged in or This status is typically used to signify that the user account has been created but user has not taken any actions within the platform. Once the user logs in to Coder, the account status will switch to _active_. -Dormant accounts do not count towards the total number of licensed seats in a Coder subscription, allowing organizations to optimize their license usage. It is important to note that the _dormant_ -status does not restrict the user from being activated manually. User administrators have the ability to activate any dormant account, granting users access to Coder's features. +Dormant accounts do not count towards the total number of licensed seats in a Coder subscription, allowing organizations to optimize their license usage. ### Suspended user From 67f3b177a7b48a0ed6b0c356c14d739f29aea3d6 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 2 Aug 2023 16:08:43 +0200 Subject: [PATCH 67/69] docs fix --- docs/admin/users.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/admin/users.md b/docs/admin/users.md index 6850fa8d371e7..85c916529a7ff 100644 --- a/docs/admin/users.md +++ b/docs/admin/users.md @@ -37,7 +37,7 @@ and can utilize all of its features and functionalities without any limitations. ### Dormant user -A user account is set to _dormant_ status when the user has not yet logged in or been active on the Coder platform. +A user account is set to _dormant_ status when the user has not yet logged in or been active on the Coder platform, or has not logged into the Coder platform for the past 90 days. This status is typically used to signify that the user account has been created but user has not taken any actions within the platform. Once the user logs in to Coder, the account status will switch to _active_. From 67c55006753f8dae27c45ddf0e2efb22089bef97 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 2 Aug 2023 16:09:35 +0200 Subject: [PATCH 68/69] fix --- docs/admin/users.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/admin/users.md b/docs/admin/users.md index 85c916529a7ff..42af5e496d7c5 100644 --- a/docs/admin/users.md +++ b/docs/admin/users.md @@ -37,7 +37,7 @@ and can utilize all of its features and functionalities without any limitations. ### Dormant user -A user account is set to _dormant_ status when the user has not yet logged in or been active on the Coder platform, or has not logged into the Coder platform for the past 90 days. +A user account is set to _dormant_ status when the user has not yet logged in, or has not logged into the Coder platform for the past 90 days. This status is typically used to signify that the user account has been created but user has not taken any actions within the platform. Once the user logs in to Coder, the account status will switch to _active_. From 11143ca035eae38d70a8d531019549d0b93020c3 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 2 Aug 2023 16:16:11 +0200 Subject: [PATCH 69/69] docs rephrase --- docs/admin/users.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/admin/users.md b/docs/admin/users.md index 42af5e496d7c5..b8edeb1619f91 100644 --- a/docs/admin/users.md +++ b/docs/admin/users.md @@ -37,9 +37,7 @@ and can utilize all of its features and functionalities without any limitations. ### Dormant user -A user account is set to _dormant_ status when the user has not yet logged in, or has not logged into the Coder platform for the past 90 days. - -This status is typically used to signify that the user account has been created but user has not taken any actions within the platform. Once the user logs in to Coder, the account status will switch to _active_. +A user account is set to _dormant_ status when they have not yet logged in, or have not logged into the Coder platform for the past 90 days. Once the user logs in to the platform, the account status will switch to _active_. Dormant accounts do not count towards the total number of licensed seats in a Coder subscription, allowing organizations to optimize their license usage.