Add search and status/role/type filters to People

Makes it practical to find people in larger orgs across GraphQL, MCP, CLI, and n8n, with page size raised to 100.

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
This commit is contained in:
Sacha Al Himdani
2026-07-27 14:46:15 +02:00
parent edb67743ae
commit d61ec8dd65
19 changed files with 624 additions and 123 deletions

View File

@@ -73,6 +73,9 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
flagOrderDir string
flagContractEnded string
flagState string
flagFilter string
flagRole string
flagKind string
flagOutput *string
)
@@ -86,6 +89,15 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
# List only active users
prb user ls --state ACTIVE
# Search users by name or email
prb user ls --filter alice
# List only admins
prb user ls --role ADMIN
# Filter by type
prb user ls --kind EMPLOYEE
# List users whose contract has ended
prb user ls --contract-ended true`,
Args: cobra.NoArgs,
@@ -153,6 +165,26 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
filter["state"] = flagState
}
if flagFilter != "" {
filter["query"] = flagFilter
}
if flagRole != "" {
if err := cmdutil.ValidateEnum("role", flagRole, []string{"OWNER", "ADMIN", "VIEWER", "AUDITOR", "EMPLOYEE"}); err != nil {
return err
}
filter["role"] = flagRole
}
if flagKind != "" {
if err := cmdutil.ValidateEnum("kind", flagKind, []string{"EMPLOYEE", "CONTRACTOR", "SERVICE_ACCOUNT"}); err != nil {
return err
}
filter["kind"] = flagKind
}
if len(filter) > 0 {
variables["filter"] = filter
}
@@ -242,6 +274,9 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
cmd.Flags().StringVar(&flagContractEnded, "contract-ended", "", "Filter by contract status (true or false)")
cmd.Flags().StringVar(&flagState, "state", "", "Filter by profile state (ACTIVE or INACTIVE)")
cmd.Flags().StringVarP(&flagFilter, "filter", "q", "", "Filter users by name or email search query")
cmd.Flags().StringVar(&flagRole, "role", "", "Filter by membership role (OWNER, ADMIN, VIEWER, AUDITOR, EMPLOYEE)")
cmd.Flags().StringVar(&flagKind, "kind", "", "Filter by profile kind (EMPLOYEE, CONTRACTOR, SERVICE_ACCOUNT)")
flagOutput = cmdutil.AddOutputFlag(cmd)
return cmd

View File

@@ -21,6 +21,7 @@
package coredata
import (
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -38,6 +39,9 @@ type (
externalID *string
state *ProfileState
source *ProfileSource
query *string
role *MembershipRole
kind *string
}
)
@@ -95,7 +99,45 @@ func (f *MembershipProfileFilter) Source() *ProfileSource {
return f.source
}
func (f *MembershipProfileFilter) WithQuery(query *string) *MembershipProfileFilter {
f.query = query
return f
}
func (f *MembershipProfileFilter) Query() *string {
return f.query
}
func (f *MembershipProfileFilter) WithRole(role MembershipRole) *MembershipProfileFilter {
f.role = &role
return f
}
func (f *MembershipProfileFilter) Role() *MembershipRole {
return f.role
}
func (f *MembershipProfileFilter) WithKind(kind *string) *MembershipProfileFilter {
f.kind = kind
return f
}
func (f *MembershipProfileFilter) Kind() *string {
return f.kind
}
func escapeLikePattern(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
func (f *MembershipProfileFilter) SQLArguments() pgx.StrictNamedArgs {
var filterQuery *string
if f.query != nil && *f.query != "" {
escaped := escapeLikePattern(*f.query)
filterQuery = &escaped
}
return pgx.StrictNamedArgs{
"filter_email": f.email,
"filter_user_name": f.userName,
@@ -106,6 +148,9 @@ func (f *MembershipProfileFilter) SQLArguments() pgx.StrictNamedArgs {
"current_date": f.currentDate,
"filter_state": f.state,
"filter_source": f.source,
"filter_query": filterQuery,
"filter_role": f.role,
"filter_kind": f.kind,
}
}
@@ -173,5 +218,36 @@ AND (
ELSE TRUE
END
)
AND (
CASE
WHEN @filter_query::text IS NOT NULL AND @filter_query::text <> '' THEN
(
p.full_name ILIKE '%' || @filter_query || '%' ESCAPE '\'
OR i.email_address ILIKE '%' || @filter_query || '%' ESCAPE '\'
OR p.position ILIKE '%' || @filter_query || '%' ESCAPE '\'
)
ELSE TRUE
END
)
AND (
CASE
WHEN @filter_role::authz_role IS NOT NULL THEN
p.identity_id IN (
SELECT identity_id
FROM iam_memberships
WHERE
organization_id = p.organization_id
AND role = @filter_role::authz_role
)
ELSE TRUE
END
)
AND (
CASE
WHEN @filter_kind::text IS NOT NULL AND @filter_kind::text <> '' THEN
p.kind = @filter_kind::text
ELSE TRUE
END
)
`
}

View File

@@ -12,6 +12,7 @@ type Organization implements Node {
last: Int
before: CursorKey
orderBy: ProfileOrder
filter: ProfileFilter
): ProfileConnection @goField(forceResolver: true)
samlConfigurations(

View File

@@ -71,6 +71,9 @@ enum ProfileOrderField
input ProfileFilter {
contractEnded: Boolean
state: ProfileState
query: String
role: MembershipRole
kind: String
}
input ProfileOrder

View File

@@ -31,9 +31,22 @@ func (r *identityResolver) Profiles(ctx context.Context, obj *types.Identity, fi
filters := coredata.NewMembershipProfileFilter(nil).WithMembership()
if filter != nil {
filters = coredata.NewMembershipProfileFilter(filter.ContractEnded).WithMembership()
if filter.State != nil {
filters.WithState(*filter.State)
}
if filter.Query != nil {
filters.WithQuery(filter.Query)
}
if filter.Role != nil {
filters.WithRole(*filter.Role)
}
if filter.Kind != nil {
filters.WithKind(filter.Kind)
}
}
if gqlutils.OnlyTotalCountSelected(ctx) {

View File

@@ -179,18 +179,37 @@ func (r *organizationResolver) HorizontalLogo(ctx context.Context, obj *types.Or
}
// Profiles is the resolver for the profiles field.
func (r *organizationResolver) Profiles(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ProfileOrderBy) (*types.ProfileConnection, error) {
func (r *organizationResolver) Profiles(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ProfileOrderBy, filter *types.ProfileFilter) (*types.ProfileConnection, error) {
if _, err := r.authorize(ctx, obj.ID, iam.ActionMembershipProfileList); err != nil {
return nil, err
}
filter := coredata.NewMembershipProfileFilter(nil).WithMembership()
filters := coredata.NewMembershipProfileFilter(nil).WithMembership()
if filter != nil {
filters = coredata.NewMembershipProfileFilter(filter.ContractEnded).WithMembership()
if filter.State != nil {
filters.WithState(*filter.State)
}
if filter.Query != nil {
filters.WithQuery(filter.Query)
}
if filter.Role != nil {
filters.WithRole(*filter.Role)
}
if filter.Kind != nil {
filters.WithKind(filter.Kind)
}
}
if gqlutils.OnlyTotalCountSelected(ctx) {
return &types.ProfileConnection{
Resolver: r,
ParentID: obj.ID,
Filters: filter,
Filters: filters,
}, nil
}
@@ -207,13 +226,13 @@ func (r *organizationResolver) Profiles(ctx context.Context, obj *types.Organiza
cursor := cursor.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.iam.OrganizationService.ListProfiles(ctx, obj.ID, cursor, filter)
page, err := r.iam.OrganizationService.ListProfiles(ctx, obj.ID, cursor, filters)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list profiles", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewProfileConnection(page, r, obj.ID, filter), nil
return types.NewProfileConnection(page, r, obj.ID, filters), nil
}
// SamlConfigurations is the resolver for the samlConfigurations field.

View File

@@ -14,6 +14,17 @@ enum ProfileState
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ProfileStateInactive")
}
enum MembershipRole
@goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipRole") {
OWNER @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleOwner")
ADMIN @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleAdmin")
EMPLOYEE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleEmployee")
VIEWER @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleViewer")
AUDITOR
@goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleAuditor")
}
type Profile implements Node {
id: ID!
fullName: String!
@@ -58,6 +69,9 @@ input ProfileOrder
input ProfileFilter {
contractEnded: Boolean
state: ProfileState
query: String
role: MembershipRole
kind: String
}
type ProfileConnection

View File

@@ -118,9 +118,22 @@ func (r *organizationResolver) Profiles(ctx context.Context, obj *types.Organiza
filters := coredata.NewMembershipProfileFilter(nil).WithMembership()
if filter != nil {
filters = coredata.NewMembershipProfileFilter(filter.ContractEnded).WithMembership()
if filter.State != nil {
filters.WithState(*filter.State)
}
if filter.Query != nil {
filters.WithQuery(filter.Query)
}
if filter.Role != nil {
filters.WithRole(*filter.Role)
}
if filter.Kind != nil {
filters.WithKind(filter.Kind)
}
}
pageOrderBy := page.OrderBy[coredata.MembershipProfileOrderField]{

View File

@@ -2771,9 +2771,23 @@ func (r *Resolver) ListUsersTool(ctx context.Context, req *mcp.CallToolRequest,
filter := coredata.NewMembershipProfileFilter(nil).WithMembership()
if input.Filter != nil {
filter = coredata.NewMembershipProfileFilter(input.Filter.ContractEnded).WithMembership()
if input.Filter.State != nil {
filter.WithState(*input.Filter.State)
}
if input.Filter.Query != nil {
filter.WithQuery(input.Filter.Query)
}
if input.Filter.Role != nil {
filter.WithRole(*input.Filter.Role)
}
if input.Filter.Kind != nil {
kind := string(*input.Filter.Kind)
filter.WithKind(&kind)
}
}
pageResult, err := r.iamSvc.OrganizationService.ListProfiles(ctx, input.OrganizationID, cursor, filter)

View File

@@ -188,6 +188,13 @@ components:
- INACTIVE
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.ProfileState
ProfileKind:
type: string
enum:
- EMPLOYEE
- CONTRACTOR
- SERVICE_ACCOUNT
ProfileSource:
type: string
enum:
@@ -572,6 +579,15 @@ components:
state:
$ref: "#/components/schemas/ProfileState"
description: Filter by profile state (ACTIVE or INACTIVE)
query:
type: string
description: Search by full name, email address, or position
role:
$ref: "#/components/schemas/MembershipRole"
description: Filter by membership role
kind:
$ref: "#/components/schemas/ProfileKind"
description: Filter by profile kind
ListUsersOutput:
type: object