diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index 3ea8f583f6c..2a525a68905 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -44572,7 +44572,7 @@ { "name": "from_memory", "in": "query", - "description": "If true, returns virtual keys from in-memory cache instead of database", + "description": "If true, returns virtual keys from in-memory cache instead of database. Filtering, sorting and pagination parameters are not applied in this mode; combining it with user_id is rejected with a 400.", "schema": { "type": "boolean", "default": false @@ -44620,6 +44620,14 @@ "type": "string" } }, + { + "name": "user_id", + "in": "query", + "description": "Filter virtual keys by assigned user ID (enterprise only; matches no virtual keys on OSS builds). Combined with customer_id/team_id using OR.", + "schema": { + "type": "string" + } + }, { "name": "sort_by", "in": "query", @@ -44681,6 +44689,16 @@ } } }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BifrostError" + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -59555,6 +59573,25 @@ "schema": { "type": "string" } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of ranked rows to return. Defaults to 100. Ignored when\n`all=true`.\n", + "schema": { + "type": "integer", + "minimum": 1, + "default": 100 + } + }, + { + "name": "all", + "in": "query", + "description": "When true, returns every ranked entity with no row cap. Intended for\nexports (CSV / PDF), which must not be truncated.\n", + "schema": { + "type": "boolean", + "default": false + } } ], "security": [ @@ -59915,6 +59952,25 @@ "schema": { "type": "string" } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of ranked rows to return. Defaults to 100. Ignored when\n`all=true`.\n", + "schema": { + "type": "integer", + "minimum": 1, + "default": 100 + } + }, + { + "name": "all", + "in": "query", + "description": "When true, returns every ranked entity with no row cap. Intended for\nexports (CSV / PDF), which must not be truncated.\n", + "schema": { + "type": "boolean", + "default": false + } } ], "security": [ diff --git a/docs/openapi/paths/management/governance.yaml b/docs/openapi/paths/management/governance.yaml index 7159fccfaa4..787488ca4eb 100644 --- a/docs/openapi/paths/management/governance.yaml +++ b/docs/openapi/paths/management/governance.yaml @@ -10,7 +10,10 @@ virtual-keys: parameters: - name: from_memory in: query - description: If true, returns virtual keys from in-memory cache instead of database + description: >- + If true, returns virtual keys from in-memory cache instead of database. + Filtering, sorting and pagination parameters are not applied in this mode; + combining it with user_id is rejected with a 400. schema: type: boolean default: false @@ -41,6 +44,13 @@ virtual-keys: description: Filter virtual keys by team ID schema: type: string + - name: user_id + in: query + description: >- + Filter virtual keys by assigned user ID (enterprise only; matches no + virtual keys on OSS builds). Combined with customer_id/team_id using OR. + schema: + type: string - name: sort_by in: query description: Field to sort by @@ -74,6 +84,8 @@ virtual-keys: application/json: schema: $ref: '../../schemas/management/governance.yaml#/ListVirtualKeysResponse' + '400': + $ref: '../../openapi.yaml#/components/responses/BadRequest' '500': $ref: '../../openapi.yaml#/components/responses/InternalError' diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index 3cf50474b53..b561fb2b9c4 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -3272,14 +3272,26 @@ func (s *RDBConfigStore) GetVirtualKeysPaginated(ctx context.Context, params Vir // on what the caller is allowed to see. baseQuery := s.ScopedDB(ctx).Model(&tables.TableVirtualKey{}) - // Virtual keys are either customer-scoped or team-scoped, never both. - // When both filters are provided, use OR to match keys belonging to either. - if params.CustomerID != "" && params.TeamID != "" { - baseQuery = baseQuery.Where("(customer_id = ? OR team_id = ?)", params.CustomerID, params.TeamID) - } else if params.CustomerID != "" { - baseQuery = baseQuery.Where("customer_id = ?", params.CustomerID) - } else if params.TeamID != "" { - baseQuery = baseQuery.Where("team_id = ?", params.TeamID) + // A virtual key is assigned to at most one of customer / team / user, so + // combining assignment filters ORs them rather than narrowing to nothing. + // UserID has no meaning in the OSS build (the VK↔user link lives in an + // enterprise table), so it fails closed instead of silently widening the + // result set; the enterprise store overrides this method to honour it. + var assignmentClauses []string + var assignmentArgs []interface{} + if params.CustomerID != "" { + assignmentClauses = append(assignmentClauses, "customer_id = ?") + assignmentArgs = append(assignmentArgs, params.CustomerID) + } + if params.TeamID != "" { + assignmentClauses = append(assignmentClauses, "team_id = ?") + assignmentArgs = append(assignmentArgs, params.TeamID) + } + if params.UserID != "" { + assignmentClauses = append(assignmentClauses, "1 = 0") + } + if len(assignmentClauses) > 0 { + baseQuery = baseQuery.Where("("+strings.Join(assignmentClauses, " OR ")+")", assignmentArgs...) } if params.Search != "" { search := "%" + strings.ToLower(params.Search) + "%" diff --git a/framework/configstore/rdb_test.go b/framework/configstore/rdb_test.go index 3a24ed7aeb4..f5f8c013d98 100644 --- a/framework/configstore/rdb_test.go +++ b/framework/configstore/rdb_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "sort" "strings" "sync" "testing" @@ -996,6 +997,113 @@ func TestUpdateRateLimit(t *testing.T) { // Virtual Key Tests // ============================================================================= +// TestGetVirtualKeysPaginated_AssignmentFilters covers how the customer / team / +// user filters compose. A virtual key is assigned to at most one of the three, so +// supplying several ORs them rather than narrowing to nothing. +// +// UserID is unresolvable in the OSS build — the VK↔user link lives in an +// enterprise-only table — so it contributes a never-true disjunct here: a +// user-only filter matches nothing instead of silently returning every key. It +// deliberately does NOT suppress a customer/team disjunct supplied alongside it; +// those are still resolvable, and dropping them would diverge from the enterprise +// store, which returns exactly that union. +func TestGetVirtualKeysPaginated_AssignmentFilters(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + require.NoError(t, store.CreateCustomer(ctx, &tables.TableCustomer{ID: "cust-1", Name: "Customer One"})) + require.NoError(t, store.CreateTeam(ctx, &tables.TableTeam{ID: "team-1", Name: "Team One"})) + + custID, teamID := "cust-1", "team-1" + seed := []*tables.TableVirtualKey{ + {ID: "vk-cust", Name: "Customer Key", Value: *schemas.NewSecretVar("vk-cust-val"), IsActive: schemas.Ptr(true), CustomerID: &custID}, + {ID: "vk-team", Name: "Team Key", Value: *schemas.NewSecretVar("vk-team-val"), IsActive: schemas.Ptr(true), TeamID: &teamID}, + {ID: "vk-none", Name: "Unassigned Key", Value: *schemas.NewSecretVar("vk-none-val"), IsActive: schemas.Ptr(true)}, + } + for _, vk := range seed { + require.NoError(t, store.CreateVirtualKey(ctx, vk)) + } + + tests := []struct { + name string + params VirtualKeyQueryParams + wantIDs []string + }{ + { + name: "no filters returns every key", + params: VirtualKeyQueryParams{}, + wantIDs: []string{"vk-cust", "vk-none", "vk-team"}, + }, + { + name: "customer only", + params: VirtualKeyQueryParams{CustomerID: "cust-1"}, + wantIDs: []string{"vk-cust"}, + }, + { + name: "team only", + params: VirtualKeyQueryParams{TeamID: "team-1"}, + wantIDs: []string{"vk-team"}, + }, + { + name: "customer or team", + params: VirtualKeyQueryParams{CustomerID: "cust-1", TeamID: "team-1"}, + wantIDs: []string{"vk-cust", "vk-team"}, + }, + { + // Fail closed: OSS cannot resolve the assignment, so it matches nothing + // rather than falling through to an unfiltered list. + name: "user only matches nothing in OSS", + params: VirtualKeyQueryParams{UserID: "user-1"}, + wantIDs: nil, + }, + { + name: "user plus customer keeps the resolvable customer disjunct", + params: VirtualKeyQueryParams{UserID: "user-1", CustomerID: "cust-1"}, + wantIDs: []string{"vk-cust"}, + }, + { + name: "user plus team keeps the resolvable team disjunct", + params: VirtualKeyQueryParams{UserID: "user-1", TeamID: "team-1"}, + wantIDs: []string{"vk-team"}, + }, + { + name: "user plus customer and team keeps both resolvable disjuncts", + params: VirtualKeyQueryParams{UserID: "user-1", CustomerID: "cust-1", TeamID: "team-1"}, + wantIDs: []string{"vk-cust", "vk-team"}, + }, + { + name: "user filter never widens a non-matching search", + params: VirtualKeyQueryParams{UserID: "user-1", Search: "Unassigned"}, + wantIDs: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + vks, totalCount, err := store.GetVirtualKeysPaginated(ctx, tt.params) + require.NoError(t, err) + + gotIDs := make([]string, 0, len(vks)) + for _, vk := range vks { + gotIDs = append(gotIDs, vk.ID) + } + sort.Strings(gotIDs) + assert.Equal(t, tt.wantIDs, nonEmptyIDs(gotIDs)) + // The count drives pagination, so it must agree with the page contents. + assert.Equal(t, int64(len(tt.wantIDs)), totalCount) + }) + } +} + +// nonEmptyIDs normalizes an empty slice to nil so table cases can express +// "matches nothing" as a nil wantIDs. +func nonEmptyIDs(ids []string) []string { + if len(ids) == 0 { + return nil + } + return ids +} + func TestCreateVirtualKey(t *testing.T) { store := setupRDBTestStore(t) ctx := context.Background() diff --git a/framework/configstore/store.go b/framework/configstore/store.go index 51368fce01b..08ca1fe5d45 100644 --- a/framework/configstore/store.go +++ b/framework/configstore/store.go @@ -21,6 +21,7 @@ type VirtualKeyQueryParams struct { Search string CustomerID string TeamID string + UserID string // Enterprise-only: filters to VKs assigned to this user; matches nothing in OSS SortBy string // name, budget_spent, created_at, status (default: created_at) Order string // asc, desc (default: asc) Export bool // When true, skip default pagination limits (caller controls limit) diff --git a/transports/bifrost-http/handlers/governance.go b/transports/bifrost-http/handlers/governance.go index 1d0c180ef31..b9f3815d550 100644 --- a/transports/bifrost-http/handlers/governance.go +++ b/transports/bifrost-http/handlers/governance.go @@ -1152,6 +1152,16 @@ func (h *GovernanceHandler) getVirtualKeys(ctx *fasthttp.RequestCtx) { // Check if "from_memory" query parameter is set to true fromMemory := string(ctx.QueryArgs().Peek("from_memory")) == "true" if fromMemory { + // The in-memory cache holds no VK↔user assignments (GovernanceData.Users carries + // only budget/rate-limit ids), so a user_id filter cannot be applied here. Reject + // the combination rather than silently dropping the filter: the database path + // fails closed on user_id, and returning every cached key instead would be the + // exact inverse of that contract. The other filters keep their long-standing + // ignored-under-from_memory behaviour. + if len(ctx.QueryArgs().Peek("user_id")) > 0 { + SendError(ctx, 400, "user_id filter is not supported with from_memory=true; omit from_memory to filter virtual keys by user") + return + } data := h.governanceManager.GetGovernanceData(ctx) if data == nil { SendError(ctx, 500, "Governance data is not available") @@ -1190,6 +1200,7 @@ func (h *GovernanceHandler) getVirtualKeys(ctx *fasthttp.RequestCtx) { search := string(ctx.QueryArgs().Peek("search")) customerID := string(ctx.QueryArgs().Peek("customer_id")) teamID := string(ctx.QueryArgs().Peek("team_id")) + userID := string(ctx.QueryArgs().Peek("user_id")) sortBy := string(ctx.QueryArgs().Peek("sort_by")) order := string(ctx.QueryArgs().Peek("order")) isExport := string(ctx.QueryArgs().Peek("export")) == "true" @@ -1197,12 +1208,13 @@ func (h *GovernanceHandler) getVirtualKeys(ctx *fasthttp.RequestCtx) { excludeAssignedVirtualKeys := string(ctx.QueryArgs().Peek("exclude_assigned_virtual_keys")) == "true" forUserAssignment := string(ctx.QueryArgs().Peek("for_user_assignment")) == "true" - if limitStr != "" || offsetStr != "" || search != "" || customerID != "" || teamID != "" || sortBy != "" || isExport || excludeAccessProfileManagedVirtual || excludeAssignedVirtualKeys || forUserAssignment { + if limitStr != "" || offsetStr != "" || search != "" || customerID != "" || teamID != "" || userID != "" || sortBy != "" || isExport || excludeAccessProfileManagedVirtual || excludeAssignedVirtualKeys || forUserAssignment { // Paginated/filtered path params := configstore.VirtualKeyQueryParams{ Search: search, CustomerID: customerID, TeamID: teamID, + UserID: userID, SortBy: sortBy, Order: order, Export: isExport, diff --git a/transports/bifrost-http/handlers/governance_test.go b/transports/bifrost-http/handlers/governance_test.go index a01913d0339..fd17dcc78d1 100644 --- a/transports/bifrost-http/handlers/governance_test.go +++ b/transports/bifrost-http/handlers/governance_test.go @@ -2409,6 +2409,73 @@ func TestGetVirtualKeys_FromMemoryTakesPrecedenceOverLimit(t *testing.T) { } } +// TestGetVirtualKeys_FromMemoryRejectsUserFilter locks in the fail-closed contract +// for the user filter. The in-memory GovernanceData carries no VK↔user assignments, +// so the filter cannot be applied there; silently ignoring it would return every +// cached key — the inverse of the DB path, which matches nothing it cannot resolve. +func TestGetVirtualKeys_FromMemoryRejectsUserFilter(t *testing.T) { + SetLogger(&mockLogger{}) + + store := &mockConfigStoreForVK{} + manager := &mockGovernanceManagerForVK{ + data: &governance.GovernanceData{ + VirtualKeys: map[string]*configstoreTables.TableVirtualKey{}, + }, + } + h := &GovernanceHandler{ + configStore: store, + governanceManager: manager, + } + + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.SetMethod("GET") + ctx.Request.SetRequestURI("/api/governance/virtual-keys?from_memory=true&user_id=user-1") + + h.getVirtualKeys(ctx) + + if ctx.Response.StatusCode() != 400 { + t.Fatalf("expected status 400, got %d: %s", ctx.Response.StatusCode(), string(ctx.Response.Body())) + } + // Rejected before any data is read, so no key ever leaves the handler. + if manager.getGovernanceDataCalls != 0 { + t.Fatalf("expected GetGovernanceData not to be called, got %d", manager.getGovernanceDataCalls) + } + if store.getVirtualKeysCalls != 0 || store.getVirtualKeysPaginatedCalls != 0 { + t.Fatalf("rejected request hit the config store: %d/%d", store.getVirtualKeysCalls, store.getVirtualKeysPaginatedCalls) + } +} + +// TestGetVirtualKeys_FromMemoryIgnoresOtherFilters pins the long-standing behaviour +// the user_id rejection deliberately does not extend to: customer_id/team_id are +// still silently ignored under from_memory, so existing consumers are unaffected. +func TestGetVirtualKeys_FromMemoryIgnoresOtherFilters(t *testing.T) { + SetLogger(&mockLogger{}) + + store := &mockConfigStoreForVK{} + manager := &mockGovernanceManagerForVK{ + data: &governance.GovernanceData{ + VirtualKeys: map[string]*configstoreTables.TableVirtualKey{}, + }, + } + h := &GovernanceHandler{ + configStore: store, + governanceManager: manager, + } + + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.SetMethod("GET") + ctx.Request.SetRequestURI("/api/governance/virtual-keys?from_memory=true&customer_id=cust-1&team_id=team-1") + + h.getVirtualKeys(ctx) + + if ctx.Response.StatusCode() != 200 { + t.Fatalf("expected status 200, got %d: %s", ctx.Response.StatusCode(), string(ctx.Response.Body())) + } + if manager.getGovernanceDataCalls != 1 { + t.Fatalf("expected GetGovernanceData to be called once, got %d", manager.getGovernanceDataCalls) + } +} + // Ensure mockLogger satisfies schemas.Logger (already defined in middlewares_test.go // but we reference it here — same package, so no redeclaration needed). var _ schemas.Logger = (*mockLogger)(nil) diff --git a/ui/app/workspace/dashboard/components/exportPopover.tsx b/ui/app/workspace/dashboard/components/exportPopover.tsx index 42122cfd198..51ed3089d43 100644 --- a/ui/app/workspace/dashboard/components/exportPopover.tsx +++ b/ui/app/workspace/dashboard/components/exportPopover.tsx @@ -92,7 +92,7 @@ export function ExportPopover({ getData, activeTab, onPreloadData, onPdfExport, - + CSV @@ -108,7 +108,7 @@ export function ExportPopover({ getData, activeTab, onPreloadData, onPdfExport, - + PDF diff --git a/ui/app/workspace/governance/virtual-keys/page.tsx b/ui/app/workspace/governance/virtual-keys/page.tsx index 2589e652448..24c90d887c2 100644 --- a/ui/app/workspace/governance/virtual-keys/page.tsx +++ b/ui/app/workspace/governance/virtual-keys/page.tsx @@ -20,6 +20,7 @@ export default function GovernanceVirtualKeysPage() { search: parseAsSafeString.withDefault(""), customer_id: parseAsString.withDefault(""), team_id: parseAsString.withDefault(""), + user_id: parseAsString.withDefault(""), offset: parseAsInteger.withDefault(0), sort_by: parseAsString.withDefault(""), order: parseAsString.withDefault(""), @@ -41,6 +42,7 @@ export default function GovernanceVirtualKeysPage() { search: debouncedSearch || undefined, customer_id: urlState.customer_id || undefined, team_id: urlState.team_id || undefined, + user_id: urlState.user_id || undefined, sort_by: (urlState.sort_by as "name" | "budget_spent" | "created_at" | "status") || undefined, order: (urlState.order as "asc" | "desc") || undefined, }, @@ -89,6 +91,10 @@ export default function GovernanceVirtualKeysPage() { setUrlState({ team_id: value || null, offset: 0 }); }; + const handleUserFilterChange = (value: string) => { + setUrlState({ user_id: value || null, offset: 0 }); + }; + const handleOffsetChange = (newOffset: number) => { setUrlState({ offset: newOffset }); }; @@ -123,6 +129,8 @@ export default function GovernanceVirtualKeysPage() { onCustomerFilterChange={handleCustomerFilterChange} teamFilter={urlState.team_id} onTeamFilterChange={handleTeamFilterChange} + userFilter={urlState.user_id} + onUserFilterChange={handleUserFilterChange} offset={urlState.offset} limit={PAGE_SIZE} onOffsetChange={handleOffsetChange} diff --git a/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx b/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx index 64f9a901e26..33f584b7d09 100644 --- a/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx +++ b/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx @@ -25,6 +25,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; import { resetDurationLabels } from "@/lib/constants/governance"; +import { getUserPicker } from "@/lib/registries/userPicker"; import { getErrorMessage, useBulkRotateVirtualKeysMutation, @@ -67,6 +68,10 @@ import VirtualKeyDetailSheet from "./virtualKeyDetailsSheet"; import { VirtualKeysEmptyState } from "./virtualKeysEmptyState"; import VirtualKeySheet from "./virtualKeySheet"; +// Registers the enterprise user picker as a side effect; a no-op in OSS builds, +// where the user filter stays hidden because no picker is registered. +import "@enterprise/lib/registrations/userPicker"; + const formatResetDuration = (duration: string) => resetDurationLabels[duration] || duration; type ExportScope = "current_page" | "all"; @@ -301,6 +306,8 @@ interface VirtualKeysTableProps { onCustomerFilterChange: (value: string) => void; teamFilter: string; onTeamFilterChange: (value: string) => void; + userFilter: string; + onUserFilterChange: (value: string) => void; offset: number; limit: number; onOffsetChange: (offset: number) => void; @@ -321,6 +328,8 @@ export default function VirtualKeysTable({ onCustomerFilterChange, teamFilter, onTeamFilterChange, + userFilter, + onUserFilterChange, offset, limit, onOffsetChange, @@ -502,6 +511,7 @@ export default function VirtualKeysTable({ search: debouncedSearch || undefined, customer_id: customerFilter || undefined, team_id: teamFilter || undefined, + user_id: userFilter || undefined, sort_by: (sortBy as "name" | "budget_spent" | "created_at" | "status") || undefined, order: (order as "asc" | "desc") || undefined, }).then((result) => { @@ -525,6 +535,7 @@ export default function VirtualKeysTable({ search: debouncedSearch || undefined, customer_id: customerFilter || undefined, team_id: teamFilter || undefined, + user_id: userFilter || undefined, sort_by: (sortBy as "name" | "budget_spent" | "created_at" | "status") || undefined, order: (order as "asc" | "desc") || undefined, }).then((result) => { @@ -556,7 +567,11 @@ export default function VirtualKeysTable({ const { copy: copyToClipboard } = useCopyToClipboard(); - const hasActiveFilters = debouncedSearch || customerFilter || teamFilter; + const hasActiveFilters = debouncedSearch || customerFilter || teamFilter || userFilter; + + // Registered by the downstream build at module load; undefined in builds + // without a user directory, which hides the user filter entirely. + const UserPicker = getUserPicker(); const toggleSort = (column: string) => { if (sortBy === column) { @@ -590,6 +605,7 @@ export default function VirtualKeysTable({ search: debouncedSearch || undefined, customer_id: customerFilter || undefined, team_id: teamFilter || undefined, + user_id: userFilter || undefined, sort_by: (sortBy as "name" | "budget_spent" | "created_at" | "status") || undefined, order: (order as "asc" | "desc") || undefined, export: true, @@ -708,7 +724,12 @@ export default function VirtualKeysTable({ {hasActiveFilters && (

Filters applied:{" "} - {[debouncedSearch && `search "${debouncedSearch}"`, customerFilter && "customer filter", teamFilter && "team filter"] + {[ + debouncedSearch && `search "${debouncedSearch}"`, + customerFilter && "customer filter", + teamFilter && "team filter", + userFilter && "user filter", + ] .filter(Boolean) .join(", ")}

@@ -838,6 +859,26 @@ export default function VirtualKeysTable({ data-testid="vk-team-filter-clear-btn" /> + {UserPicker && (customerFilter || teamFilter) && userFilter && ( + or + )} + {UserPicker && ( +
+ + onUserFilterChange("")} + data-testid="vk-user-filter-clear-btn" + /> +
+ )}
diff --git a/ui/components/entitySelectors/teamSelector.tsx b/ui/components/entitySelectors/teamSelector.tsx index 7cf5a555e12..72642ecb35d 100644 --- a/ui/components/entitySelectors/teamSelector.tsx +++ b/ui/components/entitySelectors/teamSelector.tsx @@ -2,9 +2,10 @@ // (routing rules, model limits, pricing overrides). // // Lives in OSS because the governance teams endpoint (/governance/teams) is -// OSS. Note this is a different entity from the enterprise user-groups team -// list (/teams), which carries membership and business-unit fields and is -// only used by the admin surfaces under Users & Groups. +// OSS. It reads the same teams table as the enterprise /teams list — the ids +// are interchangeable — so this is also what the Users & Groups filters pick +// with; /teams only differs in returning membership and business-unit fields +// that a picker has no use for. // // Single mode is prop-compatible with the model limit scope picker contract // ({ value, onChange, disabled, fallbackOption }). diff --git a/ui/lib/registries/userPicker.tsx b/ui/lib/registries/userPicker.tsx index 91a35101159..d4060e25850 100644 --- a/ui/lib/registries/userPicker.tsx +++ b/ui/lib/registries/userPicker.tsx @@ -18,6 +18,11 @@ export interface UserPickerProps { // picker. Callers pass the edited row's own user id when editing an // existing row. fallbackOption?: { value: string; label: string } | null; + /** Placeholder for the empty state — e.g. "All Users" when used as a filter. */ + placeholder?: string; + className?: string; + /** Extra classes for the combobox trigger, e.g. `h-9` to line up with a search input. */ + triggerClassName?: string; } let userPicker: ComponentType | undefined; diff --git a/ui/lib/store/apis/governanceApi.ts b/ui/lib/store/apis/governanceApi.ts index 84178b49eaf..b46bf441928 100644 --- a/ui/lib/store/apis/governanceApi.ts +++ b/ui/lib/store/apis/governanceApi.ts @@ -67,6 +67,7 @@ export const governanceApi = baseApi.injectEndpoints({ ...(params?.search && { search: params.search }), ...(params?.customer_id && { customer_id: params.customer_id }), ...(params?.team_id && { team_id: params.team_id }), + ...(params?.user_id && { user_id: params.user_id }), ...(params?.exclude_access_profile_managed_virtual === true && { exclude_access_profile_managed_virtual: "true", }), diff --git a/ui/lib/types/governance.ts b/ui/lib/types/governance.ts index 44be38a953c..e2372f64288 100644 --- a/ui/lib/types/governance.ts +++ b/ui/lib/types/governance.ts @@ -286,6 +286,8 @@ export interface GetVirtualKeysParams { search?: string; customer_id?: string; team_id?: string; + /** Enterprise-only: filters to virtual keys assigned to this user. */ + user_id?: string; exclude_access_profile_managed_virtual?: boolean; exclude_assigned_virtual_keys?: boolean; for_user_assignment?: boolean;