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:
+4
-2
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
Generated
+12
@@ -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"
|
||||
|
||||
Generated
+12
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
+62
-4
@@ -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
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
Generated
+4
@@ -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",
|
||||
|
||||
Generated
+51
-46
@@ -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
|
||||
|
||||
|
||||
Generated
+65
-51
@@ -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
|
||||
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
|
||||
Generated
+10
@@ -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")
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user