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/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_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]" ], diff --git a/cli/userstatus_test.go b/cli/userstatus_test.go index f5aa3c8e161ad..348559e10de5d 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 diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d5ccfb06dfc47..cc93963975f9a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10227,10 +10227,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 69b3e1f6a5453..04d819204af86 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9256,8 +9256,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/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index ada8782514381..4d4fbb8c5e78e 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -587,6 +587,14 @@ 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() { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index afeab08de9873..56e7c3d273655 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2099,6 +2099,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 database.UpdateInactiveUsersToDormantParams) ([]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 8e29bae4349fb..5766e7b84107d 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -3862,7 +3862,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, } @@ -4337,6 +4337,29 @@ func (q *FakeQuerier) UpdateGroupByID(_ context.Context, arg database.UpdateGrou return database.Group{}, sql.ErrNoRows } +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(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, + 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/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, diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 95dde653ca547..d9e8ef6d3c2ce 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 database.UpdateInactiveUsersToDormantParams) ([]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..bf1732568fd42 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 database.UpdateInactiveUsersToDormantParams) ([]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/dump.sql b/coderd/database/dump.sql index 0bf80bbfb536a..32d74edec73ed 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', @@ -561,7 +564,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/coderd/database/migrations/000144_user_status_dormant.down.sql b/coderd/database/migrations/000144_user_status_dormant.down.sql new file mode 100644 index 0000000000000..55504e0938064 --- /dev/null +++ b/coderd/database/migrations/000144_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" + +UPDATE users SET status = 'active'::user_status WHERE status = 'dormant'::user_status; diff --git a/coderd/database/migrations/000144_user_status_dormant.up.sql b/coderd/database/migrations/000144_user_status_dormant.up.sql new file mode 100644 index 0000000000000..106cc10b53b62 --- /dev/null +++ b/coderd/database/migrations/000144_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/000145_user_status_default_dormant.down.sql b/coderd/database/migrations/000145_user_status_default_dormant.down.sql new file mode 100644 index 0000000000000..6789ff246ff07 --- /dev/null +++ b/coderd/database/migrations/000145_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/000145_user_status_default_dormant.up.sql b/coderd/database/migrations/000145_user_status_default_dormant.up.sql new file mode 100644 index 0000000000000..e526e282f65be --- /dev/null +++ b/coderd/database/migrations/000145_user_status_default_dormant.up.sql @@ -0,0 +1 @@ +ALTER TABLE users ALTER COLUMN status SET DEFAULT 'dormant'::user_status; diff --git a/coderd/database/models.go b/coderd/database/models.go index 58083303b05d7..48e7e80c64833 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, } } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index be33b00bfc51b..c53c879dba5e7 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, 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 87d86e9621fbb..19c3b087eace0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5708,6 +5708,52 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User return i, err } +const updateInactiveUsersToDormant = `-- name: UpdateInactiveUsersToDormant :many +UPDATE + users +SET + status = 'dormant'::user_status, + updated_at = $1 +WHERE + 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, arg UpdateInactiveUsersToDormantParams) ([]UpdateInactiveUsersToDormantRow, error) { + rows, err := q.db.QueryContext(ctx, updateInactiveUsersToDormant, arg.UpdatedAt, arg.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..8560bf0abf696 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -250,3 +250,15 @@ SET WHERE id = $1 RETURNING *; + + +-- name: UpdateInactiveUsersToDormant :many +UPDATE + users +SET + status = 'dormant'::user_status, + updated_at = @updated_at +WHERE + last_seen_at < @last_seen_after :: timestamp + AND 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..be1e0cf0fe61b --- /dev/null +++ b/coderd/dormancy/dormantusersjob.go @@ -0,0 +1,70 @@ +package dormancy + +import ( + "context" + "database/sql" + "time" + + "golang.org/x/xerrors" + + "cdr.dev/slog" + + "github.com/coder/coder/coderd/database" +) + +const ( + // Time interval between consecutive job runs + jobInterval = 15 * time.Minute + // User accounts inactive for `accountDormancyPeriod` will be marked as dormant + 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") + + ctx, cancelFunc := context.WithCancel(ctx) + done := make(chan struct{}) + ticker := time.NewTicker(checkInterval) + go func() { + defer close(done) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + 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, 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 + } + + 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)) + } + logger.Debug(ctx, "checking user accounts is done", slog.F("num_dormant_accounts", len(updatedUsers)), slog.F("execution_time", time.Since(startTime))) + } + }() + + return func() { + cancelFunc() + <-done + } +} diff --git a/coderd/dormancy/dormantusersjob_test.go b/coderd/dormancy/dormantusersjob_test.go new file mode 100644 index 0000000000000..73224da872c6e --- /dev/null +++ b/coderd/dormancy/dormantusersjob_test.go @@ -0,0 +1,110 @@ +package dormancy_test + +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", 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", 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) + 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 dormant, suspended int + for _, row := range rows { + if row.Status == database.UserStatusDormant { + dormant++ + } else if row.Status == database.UserStatusSuspended { + suspended++ + } + } + // 6 users in total, 3 dormant, 3 suspended + return len(rows) == 9 && dormant == 3 && suspended == 3 + }, testutil.WaitShort, testutil.IntervalMedium) + + allUsers := ignoreUpdatedAt(database.ConvertUserRows(rows)) + + // Verify user status + expectedUsers := []database.User{ + asDormant(inactiveUser1), + asDormant(inactiveUser2), + asDormant(inactiveUser3), + activeUser1, + activeUser2, + activeUser3, + suspendedUser1, + suspendedUser2, + suspendedUser3, + } + require.ElementsMatch(t, allUsers, expectedUsers) +} + +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: status}) + 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 +} + +func ignoreUpdatedAt(rows []database.User) []database.User { + for i := range rows { + rows[i].UpdatedAt = time.Time{} + } + return rows +} diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 3de05b073ced5..5f0ec0dc263c7 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -393,6 +393,23 @@ 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, + 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), 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, diff --git a/coderd/userauth.go b/coderd/userauth.go index dea87821a3444..9b6ba7992bad5 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -320,6 +320,22 @@ func (api *API) loginRequest(ctx context.Context, rw http.ResponseWriter, req co return user, database.GetAuthorizationUserRolesRow{}, false } + 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, + 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 occurred. Try again later, or contact an admin for assistance.", + }) + 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 { @@ -333,7 +349,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 } @@ -1281,6 +1297,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{ diff --git a/coderd/users_test.go b/coderd/users_test.go index 0228f62f92359..eff3174ad83a2 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1048,6 +1048,35 @@ func TestPutUserSuspend(t *testing.T) { }) } +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() @@ -1368,17 +1397,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, @@ -1510,6 +1543,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. diff --git a/codersdk/users.go b/codersdk/users.go index 57d6162a2459a..daeefee5f12bf 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" ) diff --git a/docs/admin/users.md b/docs/admin/users.md index b85a4256110d1..b8edeb1619f91 100644 --- a/docs/admin/users.md +++ b/docs/admin/users.md @@ -26,6 +26,28 @@ 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 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. + +### 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 +161,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. diff --git a/docs/api/schemas.md b/docs/api/schemas.md index b9d5e3d1b78a1..5df5d4bab5e41 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -5097,6 +5097,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/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{ diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e374e46e192f1..ec2eabdade35a 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1784,8 +1784,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" 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/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, 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( diff --git a/site/src/pages/UsersPage/UsersFilter.tsx b/site/src/pages/UsersPage/UsersFilter.tsx index 5399ea2491499..e89e765cae31b 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, @@ -58,6 +59,8 @@ export const UsersFilter = ({