mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +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:
+70
-8
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user