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:
Jaayden Halko
2026-03-26 17:36:40 +07:00
committed by GitHub
parent 09d2588e2a
commit 3fb7c6264f
38 changed files with 707 additions and 161 deletions
+4 -2
View File
@@ -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
}
]
+12
View File
@@ -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"
+12
View File
@@ -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"
+7
View File
@@ -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 {
+8
View File
@@ -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)
+15
View File
@@ -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()
+4
View File
@@ -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.
+42
View File
@@ -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
+17
View File
@@ -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;
+62 -4
View File
@@ -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,
+70 -8
View File
@@ -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
}
+14 -11
View File
@@ -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"`
}
+3
View File
@@ -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 {
+21
View File
@@ -150,3 +150,24 @@ entitlement limits.
<small>Agent Workspace Build usage showing current consumption against
entitlement limits in the Licenses page.</small>
## 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.
+1
View File
@@ -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",
+4
View File
@@ -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",
+51 -46
View File
@@ -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
+65 -51
View File
@@ -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
+8
View File
@@ -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",
+10
View File
@@ -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
@@ -28,3 +28,17 @@ export const selectFeatureVisibility = (
): Record<FeatureName, boolean> => {
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")
);
};
+25
View File
@@ -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<AISeatCellProps> = ({ hasAISeat }) => {
return (
<TableCell>
{hasAISeat ? (
<CircleCheck
className="size-5 text-content-success"
aria-label="Consuming AI seat"
/>
) : (
<X
className="size-5 text-content-disabled"
aria-label="Not consuming AI seat"
/>
)}
</TableCell>
);
};
@@ -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 = {
@@ -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}
@@ -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<typeof OrganizationMembersPageView>;
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: [],
@@ -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<OrganizationMemberTableEntry> | 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<
<TableColumnHelpTooltip variant="roles" />
</Stack>
</TableHead>
<TableHead className="w-2/6">
<TableHead className={showAISeatColumn ? "w-1/6" : "w-2/6"}>
<Stack direction="row" spacing={1} alignItems="center">
<span>Groups</span>
<TableColumnHelpTooltip variant="groups" />
</Stack>
</TableHead>
<TableHead className="w-auto" />
{showAISeatColumn && (
<TableHead className="w-1/6">
<Stack direction="row" spacing={1} alignItems="center">
<span>AI add-on</span>
<TableColumnHelpTooltip variant="ai_addon" />
</Stack>
</TableHead>
)}
<TableHead className="w-px whitespace-nowrap text-right" />
</TableRow>
</TableHeader>
<TableBody>
@@ -160,29 +171,34 @@ export const OrganizationMembersPageView: FC<
}}
/>
<UserGroupsCell userGroups={member.groups} />
<TableCell>
{member.user_id !== me.id && canEditMembers && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
aria-label="Open menu"
>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => removeMember(member)}
>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{showAISeatColumn && (
<AISeatCell hasAISeat={member.has_ai_seat} />
)}
<TableCell className="w-px whitespace-nowrap text-right">
<div className="flex justify-end">
{member.user_id !== me.id && canEditMembers && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
aria-label="Open menu"
>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => removeMember(member)}
>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</TableCell>
</TableRow>
))
@@ -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<ColumnHeader, TooltipData>;
type Props = {
@@ -49,13 +57,15 @@ export const TableColumnHelpTooltip: FC<Props> = ({ variant }) => {
<HelpTooltipContent>
<HelpTooltipTitle>{variantLang.title}</HelpTooltipTitle>
<HelpTooltipText>{variantLang.text}</HelpTooltipText>
<HelpTooltipLinksGroup>
{variantLang.links.map((link) => (
<HelpTooltipLink key={link.text} href={link.href}>
{link.text}
</HelpTooltipLink>
))}
</HelpTooltipLinksGroup>
{variantLang.links.length > 0 && (
<HelpTooltipLinksGroup>
{variantLang.links.map((link) => (
<HelpTooltipLink key={link.text} href={link.href}>
{link.text}
</HelpTooltipLink>
))}
</HelpTooltipLinksGroup>
)}
</HelpTooltipContent>
</HelpTooltip>
);
@@ -37,6 +37,7 @@ describe("AccountPage", () => {
avatar_url: "",
last_seen_at: new Date().toISOString(),
login_type: "password",
has_ai_seat: false,
theme_preference: "",
...data,
}),
+30 -2
View File
@@ -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<typeof UsersPage>;
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));
}
+3
View File
@@ -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<UserPageProps> = ({ 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<UserPageProps> = ({ defaultNewPassword }) => {
isLoading={isLoading}
canEditUsers={canEditUsers}
canViewActivity={entitlements.features.audit_log.enabled}
showAISeatColumn={showAISeatColumn}
isNonInitialPage={isNonInitialPage(searchParams)}
actorID={me.id}
filterProps={{
@@ -32,7 +32,10 @@ const meta: Meta<typeof UsersPageView> = {
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,
@@ -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<UsersPageViewProps> = ({
canEditUsers,
oidcRoleSyncEnabled,
canViewActivity,
showAISeatColumn,
isLoading,
filterProps,
isNonInitialPage,
@@ -107,6 +109,7 @@ export const UsersPageView: FC<UsersPageViewProps> = ({
canEditUsers={canEditUsers}
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
canViewActivity={canViewActivity}
showAISeatColumn={showAISeatColumn}
isLoading={isLoading}
isNonInitialPage={isNonInitialPage}
actorID={actorID}
@@ -31,18 +31,34 @@ type Story = StoryObj<typeof UsersTable>;
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: [],
@@ -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<UsersTableProps> = ({
isUpdatingUserRoles,
canEditUsers,
canViewActivity,
showAISeatColumn,
isLoading,
isNonInitialPage,
actorID,
@@ -82,6 +85,14 @@ export const UsersTable: FC<UsersTableProps> = ({
<TableColumnHelpTooltip variant="groups" />
</Stack>
</TableHead>
{showAISeatColumn && (
<TableHead className="w-1/6">
<Stack direction="row" spacing={1} alignItems="center">
<span>{Language.aiAddonLabel}</span>
<TableColumnHelpTooltip variant="ai_addon" />
</Stack>
</TableHead>
)}
<TableHead className="w-1/6">{Language.loginTypeLabel}</TableHead>
<TableHead className="w-1/6">{Language.statusLabel}</TableHead>
{canEditUsers && <TableHead className="w-auto" />}
@@ -96,6 +107,7 @@ export const UsersTable: FC<UsersTableProps> = ({
isLoading={isLoading}
canEditUsers={canEditUsers}
canViewActivity={canViewActivity}
showAISeatColumn={showAISeatColumn}
isUpdatingUserRoles={isUpdatingUserRoles}
onActivateUser={onActivateUser}
onDeleteUser={onDeleteUser}
@@ -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<UsersTableBodyProps> = ({
isUpdatingUserRoles,
canEditUsers,
canViewActivity,
showAISeatColumn,
isLoading,
isNonInitialPage,
actorID,
@@ -105,6 +108,12 @@ export const UsersTableBody: FC<UsersTableBodyProps> = ({
<Skeleton variant="text" width="25%" />
</TableCell>
{showAISeatColumn && (
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
)}
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
@@ -171,6 +180,8 @@ export const UsersTableBody: FC<UsersTableBodyProps> = ({
<UserGroupsCell userGroups={groupsByUserId?.get(user.id)} />
{showAISeatColumn && <AISeatCell hasAISeat={user.has_ai_seat} />}
<TableCell>
<LoginType authMethods={authMethods!} value={user.login_type} />
</TableCell>
@@ -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,
}));
+5
View File
@@ -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: [],
};