mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat: display the AI add-on column in the UI on the Users and Organization Members tables (#23291)
## Summary Adds an entitlement-gated **AI add-on** column to both the **Users** table and the **Organization Members** table. When `ai_governance_user_limit` is entitled, each row shows whether the user is consuming an AI seat. ## Background The AI governance add-on tracks which users are consuming AI seats. Admins need visibility into per-user seat consumption directly from the user management tables. This change surfaces that information through both the site-wide Users table and the per-organization Members table, gated behind the `ai_governance_user_limit` entitlement so the column only appears when the feature is licensed. ## Implementation ### Backend - **New SQL query** `GetUserAISeatStates` (`coderd/database/queries/aiseatstate.sql`) — returns user IDs consuming an AI seat, derived from: - Users with entries in `aibridge_interceptions` (AI Bridge usage) - Users who own workspaces with `has_ai_task = true` builds (AI Tasks usage) - **SDK types** — added `has_ai_seat: boolean` to `codersdk.User` and `codersdk.OrganizationMemberWithUserData` - **Handler wiring** — both the Users list endpoint (`coderd/users.go`) and all Members endpoints (`coderd/members.go`) query AI seat state per page of user IDs and populate the response field - **dbauthz** — per-user `ActionRead` checks on `ResourceUserObject` ### Frontend - **Shared `AISeatCell` component** (`site/src/modules/users/AISeatCell.tsx`) — green `CircleCheck` for consuming, gray `X` for non-consuming - **`TableColumnHelpTooltip`** — extended with `ai_addon` variant with tooltip: *"Users with access to AI features like AI Bridge, Boundary, or Tasks who are actively consuming a seat."* - **Column visibility** gated behind `useFeatureVisibility().ai_governance_user_limit` ## Validation - Backend: dbauthz full method suite (`TestMethodTestSuite`) passes including new `GetUserAISeatStates` test - Backend: `TestGetUsers`, `TestUsersFilter`, CLI golden file tests pass - Frontend: 7/7 tests pass across `UsersPage.test.tsx` and `OrganizationMembersPage.test.tsx` (column visibility gating both directions) - `go build ./coderd/...` compiles clean - `pnpm --dir site run lint:types` passes - `make gen` clean ## Risks - **Pagination performance**: The AI seat query is scoped to the current page's user IDs (not a full table scan), keeping it efficient for paginated views. - **Semantic scope**: The workspace-side AI seat derivation uses "any build with `has_ai_task = true`" rather than "latest build only". If the product intent is latest-build-only, this can be tightened in a follow-up. --- _Generated with `mux` • Model: `anthropic:claude-opus-4-6` • Thinking: `xhigh` • Cost: `$27.25`_ <!-- mux-attribution: model=anthropic:claude-opus-4-6 thinking=xhigh costs=27.25 -->
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user