diff --git a/cli/testdata/coder_users_list_--output_json.golden b/cli/testdata/coder_users_list_--output_json.golden index 7243200f6b..afa1eb86e6 100644 --- a/cli/testdata/coder_users_list_--output_json.golden +++ b/cli/testdata/coder_users_list_--output_json.golden @@ -17,7 +17,8 @@ "name": "owner", "display_name": "Owner" } - ] + ], + "has_ai_seat": false }, { "id": "==========[second user ID]==========", @@ -31,6 +32,7 @@ "organization_ids": [ "===========[first org ID]===========" ], - "roles": [] + "roles": [], + "has_ai_seat": false } ] diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 083a5b90c0..4790ce5b20 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -17426,6 +17426,10 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.SlimRole" } }, + "has_ai_seat": { + "description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.", + "type": "boolean" + }, "is_service_account": { "type": "boolean" }, @@ -20222,6 +20226,10 @@ const docTemplate = `{ "type": "string", "format": "email" }, + "has_ai_seat": { + "description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.", + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" @@ -21071,6 +21079,10 @@ const docTemplate = `{ "type": "string", "format": "email" }, + "has_ai_seat": { + "description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.", + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 067eb22186..9d2cd58d9f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15851,6 +15851,10 @@ "$ref": "#/definitions/codersdk.SlimRole" } }, + "has_ai_seat": { + "description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.", + "type": "boolean" + }, "is_service_account": { "type": "boolean" }, @@ -18547,6 +18551,10 @@ "type": "string", "format": "email" }, + "has_ai_seat": { + "description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.", + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" @@ -19339,6 +19347,10 @@ "type": "string", "format": "email" }, + "has_ai_seat": { + "description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.", + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index c2b29ce64e..3587335549 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3921,6 +3921,13 @@ func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License, return q.db.GetUnexpiredLicenses(ctx) } +func (q *querier) GetUserAISeatStates(ctx context.Context, userIDs []uuid.UUID) ([]uuid.UUID, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil { + return nil, err + } + return q.db.GetUserAISeatStates(ctx, userIDs) +} + func (q *querier) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) { // Used by insights endpoints. Need to check both for auditors and for regular users with template acl perms. if err := q.authorizeContext(ctx, policy.ActionViewInsights, rbac.ResourceTemplate); err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 4d657e2f12..e103a343a6 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2173,6 +2173,14 @@ func (s *MethodTestSuite) TestUser() { dbm.EXPECT().GetQuotaConsumedForUser(gomock.Any(), arg).Return(int64(0), nil).AnyTimes() check.Args(arg).Asserts(u, policy.ActionRead).Returns(int64(0)) })) + s.Run("GetUserAISeatStates", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + a := testutil.Fake(s.T(), faker, database.User{}) + b := testutil.Fake(s.T(), faker, database.User{}) + ids := []uuid.UUID{a.ID, b.ID} + seatStates := []uuid.UUID{a.ID} + dbm.EXPECT().GetUserAISeatStates(gomock.Any(), ids).Return(seatStates, nil).AnyTimes() + check.Args(ids).Asserts(rbac.ResourceUser, policy.ActionRead).Returns(seatStates) + })) s.Run("GetUserByEmailOrUsername", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) arg := database.GetUserByEmailOrUsernameParams{Email: u.Email} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 1353d11d1e..888d1f723e 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2448,6 +2448,14 @@ func (m queryMetricsStore) GetUnexpiredLicenses(ctx context.Context) ([]database return r0, r1 } +func (m queryMetricsStore) GetUserAISeatStates(ctx context.Context, userIds []uuid.UUID) ([]uuid.UUID, error) { + start := time.Now() + r0, r1 := m.s.GetUserAISeatStates(ctx, userIds) + m.queryLatencies.WithLabelValues("GetUserAISeatStates").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserAISeatStates").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) { start := time.Now() r0, r1 := m.s.GetUserActivityInsights(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 13622b6a1a..0d76ba0318 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -4578,6 +4578,21 @@ func (mr *MockStoreMockRecorder) GetUnexpiredLicenses(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnexpiredLicenses", reflect.TypeOf((*MockStore)(nil).GetUnexpiredLicenses), ctx) } +// GetUserAISeatStates mocks base method. +func (m *MockStore) GetUserAISeatStates(ctx context.Context, userIds []uuid.UUID) ([]uuid.UUID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserAISeatStates", ctx, userIds) + ret0, _ := ret[0].([]uuid.UUID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserAISeatStates indicates an expected call of GetUserAISeatStates. +func (mr *MockStoreMockRecorder) GetUserAISeatStates(ctx, userIds any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserAISeatStates", reflect.TypeOf((*MockStore)(nil).GetUserAISeatStates), ctx, userIds) +} + // GetUserActivityInsights mocks base method. func (m *MockStore) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index a1fc9cc05e..ab3d698598 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -548,6 +548,10 @@ type sqlcQuerier interface { // inclusive. GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg GetTotalUsageDCManagedAgentsV1Params) (int64, error) GetUnexpiredLicenses(ctx context.Context) ([]License, error) + // Returns user IDs from the provided list that are consuming an AI seat. + // Filters to active, non-deleted, non-system users to match the canonical + // seat count query (GetActiveAISeatCount). + GetUserAISeatStates(ctx context.Context, userIds []uuid.UUID) ([]uuid.UUID, error) // GetUserActivityInsights returns the ranking with top active users. // The result can be filtered on template_ids, meaning only user data // from workspaces based on those templates will be included. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 43f85ce750..3001670bd0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1600,6 +1600,48 @@ func (q *sqlQuerier) UpsertAISeatState(ctx context.Context, arg UpsertAISeatStat return is_new, err } +const getUserAISeatStates = `-- name: GetUserAISeatStates :many +SELECT + ais.user_id +FROM + ai_seat_state ais +JOIN + users u +ON + ais.user_id = u.id +WHERE + ais.user_id = ANY($1::uuid[]) + AND u.status = 'active'::user_status + AND u.deleted = false + AND u.is_system = false +` + +// Returns user IDs from the provided list that are consuming an AI seat. +// Filters to active, non-deleted, non-system users to match the canonical +// seat count query (GetActiveAISeatCount). +func (q *sqlQuerier) GetUserAISeatStates(ctx context.Context, userIds []uuid.UUID) ([]uuid.UUID, error) { + rows, err := q.db.QueryContext(ctx, getUserAISeatStates, pq.Array(userIds)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []uuid.UUID + for rows.Next() { + var user_id uuid.UUID + if err := rows.Scan(&user_id); err != nil { + return nil, err + } + items = append(items, user_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const deleteAPIKeyByID = `-- name: DeleteAPIKeyByID :exec DELETE FROM api_keys diff --git a/coderd/database/queries/aiseatstate.sql b/coderd/database/queries/aiseatstate.sql new file mode 100644 index 0000000000..2d33db94a8 --- /dev/null +++ b/coderd/database/queries/aiseatstate.sql @@ -0,0 +1,17 @@ +-- name: GetUserAISeatStates :many +-- Returns user IDs from the provided list that are consuming an AI seat. +-- Filters to active, non-deleted, non-system users to match the canonical +-- seat count query (GetActiveAISeatCount). +SELECT + ais.user_id +FROM + ai_seat_state ais +JOIN + users u +ON + ais.user_id = u.id +WHERE + ais.user_id = ANY(@user_ids::uuid[]) + AND u.status = 'active'::user_status + AND u.deleted = false + AND u.is_system = false; diff --git a/coderd/members.go b/coderd/members.go index 8044395db8..70b8380f10 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -2,6 +2,7 @@ package coderd import ( "context" + "database/sql" "fmt" "net/http" @@ -179,7 +180,17 @@ func (api *API) organizationMember(rw http.ResponseWriter, r *http.Request) { return } - resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, rows) + var aiSeatSet map[uuid.UUID]struct{} + if api.Entitlements.Enabled(codersdk.FeatureAIGovernanceUserLimit) { + //nolint:gocritic // AI seat state is a system-level read gated by entitlement. + aiSeatSet, err = getAISeatSetByUserIDs(dbauthz.AsSystemRestricted(ctx), api.Database, []uuid.UUID{member.UserID}) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + } + + resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, rows, aiSeatSet) if err != nil { httpapi.InternalServerError(rw, err) return @@ -227,7 +238,21 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { return } - resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, members) + userIDs := make([]uuid.UUID, 0, len(members)) + for _, member := range members { + userIDs = append(userIDs, member.OrganizationMember.UserID) + } + var aiSeatSet map[uuid.UUID]struct{} + if api.Entitlements.Enabled(codersdk.FeatureAIGovernanceUserLimit) { + //nolint:gocritic // AI seat state is a system-level read gated by entitlement. + aiSeatSet, err = getAISeatSetByUserIDs(dbauthz.AsSystemRestricted(ctx), api.Database, userIDs) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + } + + resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, members, aiSeatSet) if err != nil { httpapi.InternalServerError(rw, err) return @@ -324,7 +349,21 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) { return } - members, err := convertOrganizationMembersWithUserData(ctx, api.Database, memberRows) + userIDs := make([]uuid.UUID, 0, len(memberRows)) + for _, member := range memberRows { + userIDs = append(userIDs, member.OrganizationMember.UserID) + } + var aiSeatSet map[uuid.UUID]struct{} + if api.Entitlements.Enabled(codersdk.FeatureAIGovernanceUserLimit) { + //nolint:gocritic // AI seat state is a system-level read gated by entitlement. + aiSeatSet, err = getAISeatSetByUserIDs(dbauthz.AsSystemRestricted(ctx), api.Database, userIDs) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + } + + members, err := convertOrganizationMembersWithUserData(ctx, api.Database, memberRows, aiSeatSet) if err != nil { httpapi.InternalServerError(rw, err) return @@ -337,6 +376,23 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, resp) } +func getAISeatSetByUserIDs(ctx context.Context, db database.Store, userIDs []uuid.UUID) (map[uuid.UUID]struct{}, error) { + aiSeatUserIDs, err := db.GetUserAISeatStates(ctx, userIDs) + if xerrors.Is(err, sql.ErrNoRows) { + err = nil + } + if err != nil { + return nil, err + } + + aiSeatSet := make(map[uuid.UUID]struct{}, len(aiSeatUserIDs)) + for _, uid := range aiSeatUserIDs { + aiSeatSet[uid] = struct{}{} + } + + return aiSeatSet, nil +} + // @Summary Assign role to organization member // @ID assign-role-to-organization-member // @Security CoderSessionToken @@ -508,7 +564,7 @@ func convertOrganizationMembers(ctx context.Context, db database.Store, mems []d return converted, nil } -func convertOrganizationMembersWithUserData(ctx context.Context, db database.Store, rows []database.OrganizationMembersRow) ([]codersdk.OrganizationMemberWithUserData, error) { +func convertOrganizationMembersWithUserData(ctx context.Context, db database.Store, rows []database.OrganizationMembersRow, aiSeatSet map[uuid.UUID]struct{}) ([]codersdk.OrganizationMemberWithUserData, error) { members := make([]database.OrganizationMember, 0) for _, row := range rows { members = append(members, row.OrganizationMember) @@ -524,12 +580,14 @@ func convertOrganizationMembersWithUserData(ctx context.Context, db database.Sto converted := make([]codersdk.OrganizationMemberWithUserData, 0) for i := range convertedMembers { + _, hasAISeat := aiSeatSet[rows[i].OrganizationMember.UserID] converted = append(converted, codersdk.OrganizationMemberWithUserData{ Username: rows[i].Username, AvatarURL: rows[i].AvatarURL, Name: rows[i].Name, Email: rows[i].Email, GlobalRoles: db2sdk.SlimRolesFromNames(rows[i].GlobalRoles), + HasAISeat: hasAISeat, LastSeenAt: rows[i].LastSeenAt, Status: codersdk.UserStatus(rows[i].Status), IsServiceAccount: rows[i].IsServiceAccount, diff --git a/coderd/users.go b/coderd/users.go index d00ebe5163..cfe0411bb2 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -329,8 +329,31 @@ func (api *API) users(rw http.ResponseWriter, r *http.Request) { organizationIDsByUserID[organizationIDsByMemberIDsRow.UserID] = organizationIDsByMemberIDsRow.OrganizationIDs } + var aiSeatSet map[uuid.UUID]struct{} + if api.Entitlements.Enabled(codersdk.FeatureAIGovernanceUserLimit) { + var aiSeatUserIDs []uuid.UUID + //nolint:gocritic // AI seat state is a system-level read gated by entitlement. + aiSeatUserIDs, err = api.Database.GetUserAISeatStates(dbauthz.AsSystemRestricted(ctx), userIDs) + if err != nil { + if !xerrors.Is(err, sql.ErrNoRows) { + api.Logger.Warn( + ctx, + "failed to fetch AI seat states for users", + slog.F("user_count", len(userIDs)), + slog.Error(err), + ) + } + aiSeatUserIDs = nil + } + + aiSeatSet = make(map[uuid.UUID]struct{}, len(aiSeatUserIDs)) + for _, uid := range aiSeatUserIDs { + aiSeatSet[uid] = struct{}{} + } + } + httpapi.Write(ctx, rw, http.StatusOK, codersdk.GetUsersResponse{ - Users: convertUsers(users, organizationIDsByUserID), + Users: convertUsers(users, organizationIDsByUserID, aiSeatSet), Count: int(userCount), }) } @@ -596,7 +619,9 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { Users: []telemetry.User{telemetry.ConvertUser(user)}, }) - httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.User(user, req.OrganizationIDs)) + sdkUser := db2sdk.User(user, req.OrganizationIDs) + api.enrichUserAISeat(ctx, &sdkUser) + httpapi.Write(ctx, rw, http.StatusCreated, sdkUser) } // @Summary Delete user @@ -724,7 +749,9 @@ func (api *API) userByName(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(user, organizationIDs)) + sdkUser := db2sdk.User(user, organizationIDs) + api.enrichUserAISeat(ctx, &sdkUser) + httpapi.Write(ctx, rw, http.StatusOK, sdkUser) } // Returns recent build parameters for the signed-in user. @@ -897,7 +924,9 @@ func (api *API) putUserProfile(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(updatedUserProfile, organizationIDs)) + sdkUser := db2sdk.User(updatedUserProfile, organizationIDs) + api.enrichUserAISeat(ctx, &sdkUser) + httpapi.Write(ctx, rw, http.StatusOK, sdkUser) } // @Summary Suspend user account @@ -998,7 +1027,9 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW return } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(targetUser, organizations)) + sdkUser := db2sdk.User(targetUser, organizations) + api.enrichUserAISeat(ctx, &sdkUser) + httpapi.Write(ctx, rw, http.StatusOK, sdkUser) } } @@ -1487,7 +1518,9 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(updatedUser, organizationIDs)) + sdkUser := db2sdk.User(updatedUser, organizationIDs) + api.enrichUserAISeat(ctx, &sdkUser) + httpapi.Write(ctx, rw, http.StatusOK, sdkUser) } // Returns organizations the parameterized user has access to. @@ -1701,11 +1734,40 @@ func findUserAdmins(ctx context.Context, store database.Store) ([]database.GetUs return userAdmins, nil } -func convertUsers(users []database.User, organizationIDsByUserID map[uuid.UUID][]uuid.UUID) []codersdk.User { +// enrichUserAISeat sets HasAISeat on the user when the feature is entitled. +func (api *API) enrichUserAISeat(ctx context.Context, user *codersdk.User) { + if !api.Entitlements.Enabled(codersdk.FeatureAIGovernanceUserLimit) { + return + } + + //nolint:gocritic // AI seat state is a system-level read gated by entitlement. + aiSeatUserIDs, err := api.Database.GetUserAISeatStates( + dbauthz.AsSystemRestricted(ctx), + []uuid.UUID{user.ID}, + ) + if err != nil { + if !xerrors.Is(err, sql.ErrNoRows) { + api.Logger.Warn( + ctx, + "failed to fetch AI seat state for user", + slog.F("user_id", user.ID), + slog.Error(err), + ) + } + return + } + + user.HasAISeat = len(aiSeatUserIDs) > 0 +} + +func convertUsers(users []database.User, organizationIDsByUserID map[uuid.UUID][]uuid.UUID, aiSeatSet map[uuid.UUID]struct{}) []codersdk.User { converted := make([]codersdk.User, 0, len(users)) for _, u := range users { userOrganizationIDs := organizationIDsByUserID[u.ID] - converted = append(converted, db2sdk.User(u, userOrganizationIDs)) + _, hasAISeat := aiSeatSet[u.ID] + convertedUser := db2sdk.User(u, userOrganizationIDs) + convertedUser.HasAISeat = hasAISeat + converted = append(converted, convertedUser) } return converted } diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 4f9638c705..8c17b50e56 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -73,17 +73,20 @@ type OrganizationMember struct { } type OrganizationMemberWithUserData struct { - Username string `table:"username,default_sort" json:"username"` - Name string `table:"name" json:"name,omitempty"` - AvatarURL string `json:"avatar_url,omitempty"` - Email string `json:"email"` - Status UserStatus `json:"status" enums:"active,suspended"` - LoginType LoginType `json:"login_type"` - LastSeenAt time.Time `table:"last seen at" json:"last_seen_at,omitempty" format:"date-time"` - UserCreatedAt time.Time `table:"user created at" json:"user_created_at" format:"date-time"` - UserUpdatedAt time.Time `table:"user updated at" json:"user_updated_at" format:"date-time"` - IsServiceAccount bool `json:"is_service_account,omitempty"` - GlobalRoles []SlimRole `json:"global_roles"` + Username string `table:"username,default_sort" json:"username"` + Name string `table:"name" json:"name,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Email string `json:"email"` + Status UserStatus `json:"status" enums:"active,suspended"` + LoginType LoginType `json:"login_type"` + LastSeenAt time.Time `table:"last seen at" json:"last_seen_at,omitempty" format:"date-time"` + UserCreatedAt time.Time `table:"user created at" json:"user_created_at" format:"date-time"` + UserUpdatedAt time.Time `table:"user updated at" json:"user_updated_at" format:"date-time"` + IsServiceAccount bool `json:"is_service_account,omitempty"` + GlobalRoles []SlimRole `json:"global_roles"` + // HasAISeat intentionally omits omitempty so the API always includes the + // field, even when false. + HasAISeat bool `json:"has_ai_seat"` OrganizationMember `table:"m,recursive_inline"` } diff --git a/codersdk/users.go b/codersdk/users.go index b4d7ef9d0f..d7a113b0f6 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -98,6 +98,9 @@ type User struct { OrganizationIDs []uuid.UUID `json:"organization_ids" format:"uuid"` Roles []SlimRole `json:"roles"` + // HasAISeat intentionally omits omitempty so the API always includes the + // field, even when false. + HasAISeat bool `json:"has_ai_seat"` } type GetUsersResponse struct { diff --git a/docs/ai-coder/ai-governance.md b/docs/ai-coder/ai-governance.md index 5abaf7af7c..ce437a84a1 100644 --- a/docs/ai-coder/ai-governance.md +++ b/docs/ai-coder/ai-governance.md @@ -150,3 +150,24 @@ entitlement limits. Agent Workspace Build usage showing current consumption against entitlement limits in the Licenses page. + +## Identifying AI seat consumers + +When the AI Governance add-on is licensed, the **Users** table and +**Organization Members** table display an **AI add-on** column that shows +whether each user is consuming an AI seat: + +- A green check icon indicates the user is actively consuming an AI seat. +- A gray X icon indicates the user is not consuming an AI seat. + +A user consumes an AI seat when they use AI features such as AI Bridge or +Tasks. The column helps administrators identify which users contribute to +the organization's AI seat count, making it easier to manage seat +allocations and stay within license limits. + +The **AI add-on** column only appears when the deployment has an active +`ai_governance_user_limit` entitlement. If the entitlement is not present +or the license has expired, the column is hidden. + +> **Tip:** Hover over the **AI add-on** column header for a tooltip +> describing what the column represents. diff --git a/docs/reference/api/audit.md b/docs/reference/api/audit.md index 4a648e07b6..bfdc1a259e 100644 --- a/docs/reference/api/audit.md +++ b/docs/reference/api/audit.md @@ -66,6 +66,7 @@ curl -X GET http://coder-server:8080/api/v2/audit?limit=0 \ "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 5b7cc7f8b3..7b16911c5c 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -262,6 +262,7 @@ curl -X GET http://coder-server:8080/api/v2/connectionlog?limit=0 \ "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -3355,6 +3356,7 @@ curl -X PUT http://coder-server:8080/api/v2/scim/v2/Users/{id} \ "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -3446,6 +3448,7 @@ curl -X PATCH http://coder-server:8080/api/v2/scim/v2/Users/{id} \ "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -3842,6 +3845,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl \ "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index efcc8a312b..96a30b0fa2 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -36,6 +36,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members "organization_id": "string" } ], + "has_ai_seat": true, "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", "login_type": "", @@ -68,28 +69,29 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members Status Code **200** -| Name | Type | Required | Restrictions | Description | -|------------------------|------------------------------------------------------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `» avatar_url` | string | false | | | -| `» created_at` | string(date-time) | false | | | -| `» email` | string | false | | | -| `» global_roles` | array | false | | | -| `»» display_name` | string | false | | | -| `»» name` | string | false | | | -| `»» organization_id` | string | false | | | -| `» is_service_account` | boolean | false | | | -| `» last_seen_at` | string(date-time) | false | | | -| `» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | -| `» name` | string | false | | | -| `» organization_id` | string(uuid) | false | | | -| `» roles` | array | false | | | -| `» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | -| `» updated_at` | string(date-time) | false | | | -| `» user_created_at` | string(date-time) | false | | | -| `» user_id` | string(uuid) | false | | | -| `» user_updated_at` | string(date-time) | false | | | -| `» username` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------------|------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------| +| `[array item]` | array | false | | | +| `» avatar_url` | string | false | | | +| `» created_at` | string(date-time) | false | | | +| `» email` | string | false | | | +| `» global_roles` | array | false | | | +| `»» display_name` | string | false | | | +| `»» name` | string | false | | | +| `»» organization_id` | string | false | | | +| `» has_ai_seat` | boolean | false | | Has ai seat intentionally omits omitempty so the API always includes the field, even when false. | +| `» is_service_account` | boolean | false | | | +| `» last_seen_at` | string(date-time) | false | | | +| `» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | +| `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» roles` | array | false | | | +| `» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | +| `» updated_at` | string(date-time) | false | | | +| `» user_created_at` | string(date-time) | false | | | +| `» user_id` | string(uuid) | false | | | +| `» user_updated_at` | string(date-time) | false | | | +| `» username` | string | false | | | #### Enumerated Values @@ -595,6 +597,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members "organization_id": "string" } ], + "has_ai_seat": true, "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", "login_type": "", @@ -802,6 +805,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/paginat "organization_id": "string" } ], + "has_ai_seat": true, "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", "login_type": "", @@ -836,30 +840,31 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/paginat Status Code **200** -| Name | Type | Required | Restrictions | Description | -|-------------------------|------------------------------------------------------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `» count` | integer | false | | | -| `» members` | array | false | | | -| `»» avatar_url` | string | false | | | -| `»» created_at` | string(date-time) | false | | | -| `»» email` | string | false | | | -| `»» global_roles` | array | false | | | -| `»»» display_name` | string | false | | | -| `»»» name` | string | false | | | -| `»»» organization_id` | string | false | | | -| `»» is_service_account` | boolean | false | | | -| `»» last_seen_at` | string(date-time) | false | | | -| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | -| `»» name` | string | false | | | -| `»» organization_id` | string(uuid) | false | | | -| `»» roles` | array | false | | | -| `»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | -| `»» updated_at` | string(date-time) | false | | | -| `»» user_created_at` | string(date-time) | false | | | -| `»» user_id` | string(uuid) | false | | | -| `»» user_updated_at` | string(date-time) | false | | | -| `»» username` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|-------------------------|------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------| +| `[array item]` | array | false | | | +| `» count` | integer | false | | | +| `» members` | array | false | | | +| `»» avatar_url` | string | false | | | +| `»» created_at` | string(date-time) | false | | | +| `»» email` | string | false | | | +| `»» global_roles` | array | false | | | +| `»»» display_name` | string | false | | | +| `»»» name` | string | false | | | +| `»»» organization_id` | string | false | | | +| `»» has_ai_seat` | boolean | false | | Has ai seat intentionally omits omitempty so the API always includes the field, even when false. | +| `»» is_service_account` | boolean | false | | | +| `»» last_seen_at` | string(date-time) | false | | | +| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | +| `»» name` | string | false | | | +| `»» organization_id` | string(uuid) | false | | | +| `»» roles` | array | false | | | +| `»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | +| `»» updated_at` | string(date-time) | false | | | +| `»» user_created_at` | string(date-time) | false | | | +| `»» user_id` | string(uuid) | false | | | +| `»» user_updated_at` | string(date-time) | false | | | +| `»» username` | string | false | | | #### Enumerated Values diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 6c328e8042..96ea900ffc 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1296,6 +1296,7 @@ "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -1387,6 +1388,7 @@ "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -1737,6 +1739,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -1813,6 +1816,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -1882,6 +1886,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -4524,6 +4529,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -6127,6 +6133,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "organization_id": "string" } ], + "has_ai_seat": true, "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", "login_type": "", @@ -6150,24 +6157,25 @@ Only certain features set these fields: - FeatureManagedAgentLimit| ### Properties -| Name | Type | Required | Restrictions | Description | -|----------------------|-------------------------------------------------|----------|--------------|-------------| -| `avatar_url` | string | false | | | -| `created_at` | string | false | | | -| `email` | string | false | | | -| `global_roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | -| `is_service_account` | boolean | false | | | -| `last_seen_at` | string | false | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | -| `name` | string | false | | | -| `organization_id` | string | false | | | -| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | -| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | -| `updated_at` | string | false | | | -| `user_created_at` | string | false | | | -| `user_id` | string | false | | | -| `user_updated_at` | string | false | | | -| `username` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|----------------------|-------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------| +| `avatar_url` | string | false | | | +| `created_at` | string | false | | | +| `email` | string | false | | | +| `global_roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `has_ai_seat` | boolean | false | | Has ai seat intentionally omits omitempty so the API always includes the field, even when false. | +| `is_service_account` | boolean | false | | | +| `last_seen_at` | string | false | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | +| `name` | string | false | | | +| `organization_id` | string | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | +| `updated_at` | string | false | | | +| `user_created_at` | string | false | | | +| `user_id` | string | false | | | +| `user_updated_at` | string | false | | | +| `username` | string | false | | | #### Enumerated Values @@ -6431,6 +6439,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "organization_id": "string" } ], + "has_ai_seat": true, "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", "login_type": "", @@ -9070,6 +9079,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -9519,6 +9529,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -9544,23 +9555,24 @@ Restarts will only happen on weekdays in this list on weeks which line up with W ### Properties -| Name | Type | Required | Restrictions | Description | -|----------------------|-------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------| -| `avatar_url` | string | false | | | -| `created_at` | string | true | | | -| `email` | string | true | | | -| `id` | string | true | | | -| `is_service_account` | boolean | false | | | -| `last_seen_at` | string | false | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | -| `name` | string | false | | | -| `organization_ids` | array of string | false | | | -| `role` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | -| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | -| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | -| `theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. | -| `updated_at` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +|----------------------|-------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------| +| `avatar_url` | string | false | | | +| `created_at` | string | true | | | +| `email` | string | true | | | +| `has_ai_seat` | boolean | false | | Has ai seat intentionally omits omitempty so the API always includes the field, even when false. | +| `id` | string | true | | | +| `is_service_account` | boolean | false | | | +| `last_seen_at` | string | false | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | +| `name` | string | false | | | +| `organization_ids` | array of string | false | | | +| `role` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | +| `theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. | +| `updated_at` | string | false | | | +| `username` | string | true | | | #### Enumerated Values @@ -10415,6 +10427,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -10439,22 +10452,23 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|----------------------|-------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------| -| `avatar_url` | string | false | | | -| `created_at` | string | true | | | -| `email` | string | true | | | -| `id` | string | true | | | -| `is_service_account` | boolean | false | | | -| `last_seen_at` | string | false | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | -| `name` | string | false | | | -| `organization_ids` | array of string | false | | | -| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | -| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | -| `theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. | -| `updated_at` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +|----------------------|-------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------| +| `avatar_url` | string | false | | | +| `created_at` | string | true | | | +| `email` | string | true | | | +| `has_ai_seat` | boolean | false | | Has ai seat intentionally omits omitempty so the API always includes the field, even when false. | +| `id` | string | true | | | +| `is_service_account` | boolean | false | | | +| `last_seen_at` | string | false | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | +| `name` | string | false | | | +| `organization_ids` | array of string | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | +| `theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. | +| `updated_at` | string | false | | | +| `username` | string | true | | | #### Enumerated Values diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 79501986c5..51303309a3 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -34,6 +34,7 @@ curl -X GET http://coder-server:8080/api/v2/users \ "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -112,6 +113,7 @@ curl -X POST http://coder-server:8080/api/v2/users \ "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -455,6 +457,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user} \ "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -1389,6 +1392,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/profile \ "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -1447,6 +1451,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/roles \ "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -1517,6 +1522,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/roles \ "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -1575,6 +1581,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/activate \ "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", @@ -1633,6 +1640,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/suspend \ "avatar_url": "http://example.com", "created_at": "2019-08-24T14:15:22Z", "email": "user@example.com", + "has_ai_seat": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_service_account": true, "last_seen_at": "2019-08-24T14:15:22Z", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6b4a5513dc..7488acef07 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4668,6 +4668,11 @@ export interface OrganizationMemberWithUserData extends OrganizationMember { readonly user_updated_at: string; readonly is_service_account?: boolean; readonly global_roles: readonly SlimRole[]; + /** + * HasAISeat intentionally omits omitempty so the API always includes the + * field, even when false. + */ + readonly has_ai_seat: boolean; } // From codersdk/users.go @@ -7444,6 +7449,11 @@ export interface UsageStatsConfig { export interface User extends ReducedUser { readonly organization_ids: readonly string[]; readonly roles: readonly SlimRole[]; + /** + * HasAISeat intentionally omits omitempty so the API always includes the + * field, even when false. + */ + readonly has_ai_seat: boolean; } // From codersdk/insights.go diff --git a/site/src/modules/dashboard/entitlements.ts b/site/src/modules/dashboard/entitlements.ts index cd567bc3f6..a11af9faed 100644 --- a/site/src/modules/dashboard/entitlements.ts +++ b/site/src/modules/dashboard/entitlements.ts @@ -28,3 +28,17 @@ export const selectFeatureVisibility = ( ): Record => { return getFeatureVisibility(entitlements.has_license, entitlements.features); }; + +/** + * Keep the AI seats column visible while in grace period so admins can + * identify who is consuming seats while remediating overages. + */ +export const shouldShowAISeatColumn = (entitlements: Entitlements): boolean => { + const aiGovernanceUserLimit = entitlements.features.ai_governance_user_limit; + return ( + entitlements.has_license && + aiGovernanceUserLimit.enabled && + (aiGovernanceUserLimit.entitlement === "entitled" || + aiGovernanceUserLimit.entitlement === "grace_period") + ); +}; diff --git a/site/src/modules/users/AISeatCell.tsx b/site/src/modules/users/AISeatCell.tsx new file mode 100644 index 0000000000..d2ff6b3769 --- /dev/null +++ b/site/src/modules/users/AISeatCell.tsx @@ -0,0 +1,25 @@ +import { CircleCheck, X } from "lucide-react"; +import type { FC } from "react"; +import { TableCell } from "#/components/Table/Table"; + +interface AISeatCellProps { + hasAISeat: boolean; +} + +export const AISeatCell: FC = ({ hasAISeat }) => { + return ( + + {hasAISeat ? ( + + ) : ( + + )} + + ); +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsPageView.stories.tsx index 1be968782b..ec2f9205e8 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPageView.stories.tsx @@ -59,6 +59,7 @@ const mockUserProfile: TypesGen.User = { roles: [], last_seen_at: "2026-03-11T10:00:00Z", login_type: "password", + has_ai_seat: false, }; const mockCostSummary: TypesGen.ChatCostSummary = { diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index 101cd5c6a0..c1778076ad 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -1,5 +1,7 @@ import { useAuthenticated } from "hooks"; import { usePaginatedQuery } from "hooks/usePaginatedQuery"; +import { shouldShowAISeatColumn } from "modules/dashboard/entitlements"; +import { useDashboard } from "modules/dashboard/useDashboard"; import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; import { RequirePermission } from "modules/permissions/RequirePermission"; import { type FC, useState } from "react"; @@ -32,7 +34,9 @@ const OrganizationMembersPage: FC = () => { organization: string; }; const { organization, organizationPermissions } = useOrganizationSettings(); + const { entitlements } = useDashboard(); const searchParamsResult = useSearchParams(); + const showAISeatColumn = shouldShowAISeatColumn(entitlements); const organizationRolesQuery = useQuery(organizationRoles(organizationName)); const groupsByUserIdQuery = useQuery( @@ -99,6 +103,7 @@ const OrganizationMembersPage: FC = () => { } isAddingMember={addMemberMutation.isPending} isUpdatingMemberRoles={updateMemberRolesMutation.isPending} + showAISeatColumn={showAISeatColumn} me={me} members={members} membersQuery={membersQuery} diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx index 84aa26e9e8..b5d0cb647c 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx @@ -7,6 +7,7 @@ import { } from "testHelpers/entities"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { UsePaginatedQueryResult } from "hooks/usePaginatedQuery"; +import { expect, within } from "storybook/test"; import { mockSuccessResult } from "#/components/PaginationWidget/PaginationContainer.mocks"; import { OrganizationMembersPageView } from "./OrganizationMembersPageView"; @@ -43,6 +44,34 @@ type Story = StoryObj; export const Default: Story = {}; +export const WithAIAddonColumn: Story = { + args: { + showAISeatColumn: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const header = await canvas.findByRole("columnheader", { + name: /AI add-on/i, + }); + + await expect(header).toBeVisible(); + }, +}; + +export const WithoutAIAddonColumn: Story = { + args: { + showAISeatColumn: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole("columnheader", { name: "User" }); + + await expect( + canvas.queryByRole("columnheader", { name: /AI add-on/i }), + ).not.toBeInTheDocument(); + }, +}; + export const NoMembers: Story = { args: { members: [], diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx index 4a6d03553f..b08b33c6a9 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx @@ -1,5 +1,6 @@ import type { PaginationResultInfo } from "hooks/usePaginatedQuery"; import { EllipsisVertical, TriangleAlert, UserPlusIcon } from "lucide-react"; +import { AISeatCell } from "modules/users/AISeatCell"; import { UserGroupsCell } from "pages/UsersPage/UsersTable/UserGroupsCell"; import { type FC, useState } from "react"; import { toast } from "sonner"; @@ -47,6 +48,7 @@ interface OrganizationMembersPageViewProps { error: unknown; isAddingMember: boolean; isUpdatingMemberRoles: boolean; + showAISeatColumn?: boolean; me: User; members: Array | undefined; membersQuery: PaginationResultInfo & { @@ -73,6 +75,7 @@ export const OrganizationMembersPageView: FC< error, isAddingMember, isUpdatingMemberRoles, + showAISeatColumn, me, membersQuery, members, @@ -115,13 +118,21 @@ export const OrganizationMembersPageView: FC< - + Groups - + {showAISeatColumn && ( + + + AI add-on + + + + )} + @@ -160,29 +171,34 @@ export const OrganizationMembersPageView: FC< }} /> - - {member.user_id !== me.id && canEditMembers && ( - - - - - - removeMember(member)} - > - Remove - - - - )} + {showAISeatColumn && ( + + )} + +
+ {member.user_id !== me.id && canEditMembers && ( + + + + + + removeMember(member)} + > + Remove + + + + )} +
)) diff --git a/site/src/pages/OrganizationSettingsPage/UserTable/TableColumnHelpTooltip.tsx b/site/src/pages/OrganizationSettingsPage/UserTable/TableColumnHelpTooltip.tsx index 9e05bc06a3..8b73dd01b9 100644 --- a/site/src/pages/OrganizationSettingsPage/UserTable/TableColumnHelpTooltip.tsx +++ b/site/src/pages/OrganizationSettingsPage/UserTable/TableColumnHelpTooltip.tsx @@ -10,7 +10,7 @@ import { HelpTooltipTitle, } from "#/components/HelpTooltip/HelpTooltip"; -type ColumnHeader = "roles" | "groups"; +type ColumnHeader = "roles" | "groups" | "ai_addon"; type TooltipData = { title: string; @@ -34,6 +34,14 @@ const Language = { "to specific templates. View our docs on how to use groups.", links: [{ text: "User Groups", href: docs("/admin/users/groups-roles") }], }, + + ai_addon: { + title: "What is the AI add-on?", + text: + "Users with access to AI features like AI Bridge or Tasks " + + "who are actively consuming a seat.", + links: [], + }, } as const satisfies Record; type Props = { @@ -49,13 +57,15 @@ export const TableColumnHelpTooltip: FC = ({ variant }) => { {variantLang.title} {variantLang.text} - - {variantLang.links.map((link) => ( - - {link.text} - - ))} - + {variantLang.links.length > 0 && ( + + {variantLang.links.map((link) => ( + + {link.text} + + ))} + + )} ); diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx index 2425df8353..7c9092c3e4 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx @@ -37,6 +37,7 @@ describe("AccountPage", () => { avatar_url: "", last_seen_at: new Date().toISOString(), login_type: "password", + has_ai_seat: false, theme_preference: "", ...data, }), diff --git a/site/src/pages/UsersPage/UsersPage.stories.tsx b/site/src/pages/UsersPage/UsersPage.stories.tsx index ba3f096a1c..a90528da19 100644 --- a/site/src/pages/UsersPage/UsersPage.stories.tsx +++ b/site/src/pages/UsersPage/UsersPage.stories.tsx @@ -8,7 +8,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { MockGroups } from "pages/UsersPage/storybookData/groups"; import { MockRoles } from "pages/UsersPage/storybookData/roles"; import { MockUsers } from "pages/UsersPage/storybookData/users"; -import { screen, spyOn, userEvent, within } from "storybook/test"; +import { expect, screen, spyOn, userEvent, within } from "storybook/test"; import { API } from "#/api/api"; import { deploymentConfigQueryKey } from "#/api/queries/deployment"; import { groupsQueryKey } from "#/api/queries/groups"; @@ -82,6 +82,34 @@ type Story = StoryObj; export const Loaded: Story = {}; +export const WithAIAddonColumn: Story = { + parameters: { + features: ["ai_governance_user_limit"], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const header = await canvas.findByRole("columnheader", { + name: /AI add-on/i, + }); + + await expect(header).toBeVisible(); + }, +}; + +export const WithoutAIAddonColumn: Story = { + parameters: { + features: ["audit_log"], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole("columnheader", { name: "User" }); + + await expect( + canvas.queryByRole("columnheader", { name: /AI add-on/i }), + ).not.toBeInTheDocument(); + }, +}; + export const SuspendUserSuccess: Story = { play: async ({ canvasElement }) => { const user = userEvent.setup(); @@ -372,5 +400,5 @@ export const UpdateUserRoleError: Story = { }; function replaceUser(users: User[], index: number, user: User) { - return users.map((u, i) => (i === index ? user : u)); + return users.map((u, i) => (i === index ? { ...u, ...user } : u)); } diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 33d4ccd6b2..b59da844da 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -1,5 +1,6 @@ import { useAuthenticated } from "hooks"; import { usePaginatedQuery } from "hooks/usePaginatedQuery"; +import { shouldShowAISeatColumn } from "modules/dashboard/entitlements"; import { useDashboard } from "modules/dashboard/useDashboard"; import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; @@ -40,6 +41,7 @@ const UsersPage: FC = ({ defaultNewPassword }) => { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const { entitlements } = useDashboard(); + const showAISeatColumn = shouldShowAISeatColumn(entitlements); const groupsByUserIdQuery = useQuery(groupsByUserId()); const authMethodsQuery = useQuery(authMethods()); @@ -144,6 +146,7 @@ const UsersPage: FC = ({ defaultNewPassword }) => { isLoading={isLoading} canEditUsers={canEditUsers} canViewActivity={entitlements.features.audit_log.enabled} + showAISeatColumn={showAISeatColumn} isNonInitialPage={isNonInitialPage(searchParams)} actorID={me.id} filterProps={{ diff --git a/site/src/pages/UsersPage/UsersPageView.stories.tsx b/site/src/pages/UsersPage/UsersPageView.stories.tsx index 5897d15b40..034e64af9c 100644 --- a/site/src/pages/UsersPage/UsersPageView.stories.tsx +++ b/site/src/pages/UsersPage/UsersPageView.stories.tsx @@ -32,7 +32,10 @@ const meta: Meta = { component: UsersPageView, args: { isNonInitialPage: false, - users: [MockUserOwner, MockUserMember], + users: [ + { ...MockUserOwner, has_ai_seat: false }, + { ...MockUserMember, has_ai_seat: false }, + ], roles: MockAssignableSiteRoles, canEditUsers: true, filterProps: defaultFilterProps, diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 058432c164..389afef267 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -23,6 +23,7 @@ interface UsersPageViewProps { canEditUsers: boolean; oidcRoleSyncEnabled: boolean; canViewActivity?: boolean; + showAISeatColumn?: boolean; isLoading: boolean; authMethods?: TypesGen.AuthMethods; onSuspendUser: (user: TypesGen.User) => void; @@ -60,6 +61,7 @@ export const UsersPageView: FC = ({ canEditUsers, oidcRoleSyncEnabled, canViewActivity, + showAISeatColumn, isLoading, filterProps, isNonInitialPage, @@ -107,6 +109,7 @@ export const UsersPageView: FC = ({ canEditUsers={canEditUsers} oidcRoleSyncEnabled={oidcRoleSyncEnabled} canViewActivity={canViewActivity} + showAISeatColumn={showAISeatColumn} isLoading={isLoading} isNonInitialPage={isNonInitialPage} actorID={actorID} diff --git a/site/src/pages/UsersPage/UsersTable/UsersTable.stories.tsx b/site/src/pages/UsersPage/UsersTable/UsersTable.stories.tsx index aceee691b2..a9fccc50e5 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTable.stories.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTable.stories.tsx @@ -31,18 +31,34 @@ type Story = StoryObj; export const Example: Story = { args: { - users: [MockUserOwner, MockUserMember], + users: [ + { ...MockUserOwner, has_ai_seat: false }, + { ...MockUserMember, has_ai_seat: false }, + ], roles: MockAssignableSiteRoles, canEditUsers: false, groupsByUserId: mockGroupsByUserId, }, }; +export const ExampleWithAISeatColumn: Story = { + args: { + users: [ + { ...MockUserOwner, has_ai_seat: true }, + { ...MockUserMember, has_ai_seat: false }, + ], + roles: MockAssignableSiteRoles, + canEditUsers: false, + groupsByUserId: mockGroupsByUserId, + showAISeatColumn: true, + }, +}; + export const Editable: Story = { args: { users: [ - MockUserOwner, - MockUserMember, + { ...MockUserOwner, has_ai_seat: false }, + { ...MockUserMember, has_ai_seat: false }, { ...MockUserOwner, username: "John Doe", @@ -54,6 +70,7 @@ export const Editable: Story = { MockAuditorRole, ], status: "dormant", + has_ai_seat: false, }, { ...MockUserOwner, @@ -61,6 +78,7 @@ export const Editable: Story = { email: "roger.moore@coder.com", roles: [], status: "suspended", + has_ai_seat: false, }, { ...MockUserOwner, @@ -69,6 +87,7 @@ export const Editable: Story = { roles: [], status: "active", login_type: "oidc", + has_ai_seat: false, }, ], roles: MockAssignableSiteRoles, @@ -78,6 +97,50 @@ export const Editable: Story = { }, }; +export const EditableWithAISeatColumn: Story = { + args: { + users: [ + { ...MockUserOwner, has_ai_seat: true }, + { ...MockUserMember, has_ai_seat: false }, + { + ...MockUserOwner, + username: "John Doe", + email: "john.doe@coder.com", + roles: [ + MockUserAdminRole, + MockTemplateAdminRole, + MockMemberRole, + MockAuditorRole, + ], + status: "dormant", + has_ai_seat: false, + }, + { + ...MockUserOwner, + username: "Roger Moore", + email: "roger.moore@coder.com", + roles: [], + status: "suspended", + has_ai_seat: false, + }, + { + ...MockUserOwner, + username: "OIDC User", + email: "oidc.user@coder.com", + roles: [], + status: "active", + login_type: "oidc", + has_ai_seat: false, + }, + ], + roles: MockAssignableSiteRoles, + canEditUsers: true, + canViewActivity: true, + groupsByUserId: mockGroupsByUserId, + showAISeatColumn: true, + }, +}; + export const Empty: Story = { args: { users: [], diff --git a/site/src/pages/UsersPage/UsersTable/UsersTable.tsx b/site/src/pages/UsersPage/UsersTable/UsersTable.tsx index 29951944ad..a93897149e 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTable.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTable.tsx @@ -16,6 +16,7 @@ const Language = { usernameLabel: "User", rolesLabel: "Roles", groupsLabel: "Groups", + aiAddonLabel: "AI add-on", statusLabel: "Status", lastSeenLabel: "Last Seen", loginTypeLabel: "Login Type", @@ -28,6 +29,7 @@ interface UsersTableProps { isUpdatingUserRoles?: boolean; canEditUsers: boolean; canViewActivity?: boolean; + showAISeatColumn?: boolean; isLoading: boolean; onSuspendUser: (user: TypesGen.User) => void; onActivateUser: (user: TypesGen.User) => void; @@ -58,6 +60,7 @@ export const UsersTable: FC = ({ isUpdatingUserRoles, canEditUsers, canViewActivity, + showAISeatColumn, isLoading, isNonInitialPage, actorID, @@ -82,6 +85,14 @@ export const UsersTable: FC = ({
+ {showAISeatColumn && ( + + + {Language.aiAddonLabel} + + + + )} {Language.loginTypeLabel} {Language.statusLabel} {canEditUsers && } @@ -96,6 +107,7 @@ export const UsersTable: FC = ({ isLoading={isLoading} canEditUsers={canEditUsers} canViewActivity={canViewActivity} + showAISeatColumn={showAISeatColumn} isUpdatingUserRoles={isUpdatingUserRoles} onActivateUser={onActivateUser} onDeleteUser={onDeleteUser} diff --git a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx index 9992cf5901..8be0a4b227 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx @@ -10,6 +10,7 @@ import { TrashIcon, UserLockIcon, } from "lucide-react"; +import { AISeatCell } from "modules/users/AISeatCell"; import type { FC } from "react"; import { useNavigate } from "react-router"; import type { GroupsByUserId } from "#/api/queries/groups"; @@ -48,6 +49,7 @@ interface UsersTableBodyProps { canEditUsers: boolean; isLoading: boolean; canViewActivity?: boolean; + showAISeatColumn?: boolean; onSuspendUser: (user: TypesGen.User) => void; onDeleteUser: (user: TypesGen.User) => void; onListWorkspaces: (user: TypesGen.User) => void; @@ -80,6 +82,7 @@ export const UsersTableBody: FC = ({ isUpdatingUserRoles, canEditUsers, canViewActivity, + showAISeatColumn, isLoading, isNonInitialPage, actorID, @@ -105,6 +108,12 @@ export const UsersTableBody: FC = ({ + {showAISeatColumn && ( + + + + )} + @@ -171,6 +180,8 @@ export const UsersTableBody: FC = ({ + {showAISeatColumn && } + diff --git a/site/src/pages/UsersPage/storybookData/users.ts b/site/src/pages/UsersPage/storybookData/users.ts index 063f83bf1c..b21e2f1fa9 100644 --- a/site/src/pages/UsersPage/storybookData/users.ts +++ b/site/src/pages/UsersPage/storybookData/users.ts @@ -565,6 +565,7 @@ export const MockUsers: User[] = [ ...u, ...fakeUserData[i], avatar_url: "", + has_ai_seat: false, status: u.status as UserStatus, login_type: u.login_type as LoginType, })); diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 368cb435f4..6d73cac8d2 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -519,6 +519,7 @@ export const MockUserOwner: TypesGen.User = { avatar_url: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4", last_seen_at: "", login_type: "password", + has_ai_seat: false, name: "", }; @@ -534,6 +535,7 @@ export const MockUserMember: TypesGen.User = { avatar_url: "", last_seen_at: "2022-09-14T19:12:21Z", login_type: "oidc", + has_ai_seat: false, name: "Mock User The Second", }; @@ -549,6 +551,7 @@ export const SuspendedMockUser: TypesGen.User = { avatar_url: "", last_seen_at: "", login_type: "password", + has_ai_seat: false, name: "", }; @@ -576,6 +579,7 @@ export const MockOrganizationMember: TypesGen.OrganizationMemberWithUserData = { name: MockUserOwner.name, avatar_url: MockUserOwner.avatar_url, global_roles: MockUserOwner.roles, + has_ai_seat: false, roles: [], }; @@ -595,6 +599,7 @@ export const MockOrganizationMember2: TypesGen.OrganizationMemberWithUserData = name: MockUserMember.name, avatar_url: MockUserMember.avatar_url, global_roles: MockUserMember.roles, + has_ai_seat: false, roles: [], };