diff --git a/apps/console/src/_locales/en-US.json b/apps/console/src/_locales/en-US.json index 259204a8d..aabda8013 100644 --- a/apps/console/src/_locales/en-US.json +++ b/apps/console/src/_locales/en-US.json @@ -1019,6 +1019,13 @@ "contractor": "Contractor", "serviceAccount": "Service account" }, + "membershipRole": { + "owner": "Owner", + "admin": "Admin", + "viewer": "Viewer", + "auditor": "Auditor", + "employee": "Employee" + }, "registryStatus": { "open": "Open", "inProgress": "In Progress", @@ -1351,20 +1358,20 @@ "addPersonDialog": { "title": "Add Person" }, "peopleList": { "columns": { "name": "Name", "status": "Status", "email": "Email", "role": "Role", "createdOn": "Created on" }, - "empty": "No people" + "empty": "No people", + "searchPlaceholder": "Search people...", + "filters": { "allStatuses": "All statuses", "active": "Active", "inactive": "Inactive", "allRoles": "All roles", "allTypes": "All types" } }, "peopleListItem": { "messages": { "invitationSent": "Invitation sent successfully", "roleUpdated": "Role updated successfully", "archived": "Person archived successfully", "removed": "Person removed successfully" }, "errors": { "sendInvitation": "Failed to send invitation", "updateRole": "Failed to update role", "archive": "Failed to archive person", "remove": "Failed to remove person" }, "confirmations": { "sendActivationEmail": "Send the activation email to {{name}}?", "archive": "Are you sure you want to archive {{name}}?", "remove": "Are you sure you want to remove {{name}}?" }, - "roles": { "owner": "Owner", "admin": "Admin", "viewer": "Viewer", "auditor": "Auditor", "employee": "Employee" }, "actions": { "send": "Send", "sendActivationMail": "Send activation mail", "resendActivationMail": "Resend activation mail", "archivePerson": "Archive person", "removePerson": "Remove person" } }, "personForm": { "messages": { "created": "Person created successfully.", "updated": "Person updated successfully." }, "errors": { "create": "Failed to create person", "update": "Failed to update person" }, "fields": { "fullName": "Full name *", "emailAddress": "Email Address *", "role": "Role *", "type": "Type", "position": "Position", "positionPlaceholder": "e.g. CEO, CFO, etc.", "contractStartDate": "Contract start date", "contractEndDate": "Contract end date" }, - "roles": { "owner": "Owner", "admin": "Admin", "viewer": "Viewer", "auditor": "Auditor", "employee": "Employee" }, "roleDescriptions": { "owner": "Full access to everything", "admin": "Full access except organization setup and API keys", "viewer": "Read-only access", "auditor": "Read-only access without settings and tasks", "employee": "Access to employee page" }, "kinds": { "EMPLOYEE": "Employee", "CONTRACTOR": "Contractor", "SERVICE_ACCOUNT": "Service account" }, "actions": { "update": "Update", "create": "Create" } diff --git a/apps/console/src/_locales/fr-FR.json b/apps/console/src/_locales/fr-FR.json index 80b0849e8..3551a4b7c 100644 --- a/apps/console/src/_locales/fr-FR.json +++ b/apps/console/src/_locales/fr-FR.json @@ -1676,6 +1676,13 @@ "contractor": "Prestataire", "serviceAccount": "Compte de service" }, + "membershipRole": { + "owner": "Propriétaire", + "admin": "Administrateur", + "viewer": "Lecteur", + "auditor": "Auditeur", + "employee": "Employé" + }, "registryStatus": { "open": "Ouvert", "inProgress": "En cours", @@ -2419,7 +2426,15 @@ "role": "Rôle", "createdOn": "Créé le" }, - "empty": "Aucune personne" + "empty": "Aucune personne", + "searchPlaceholder": "Rechercher des personnes...", + "filters": { + "allStatuses": "Tous les statuts", + "active": "Actif", + "inactive": "Inactif", + "allRoles": "Tous les rôles", + "allTypes": "Tous les types" + } }, "peopleListItem": { "messages": { @@ -2439,13 +2454,6 @@ "archive": "Voulez-vous vraiment archiver {{name}} ?", "remove": "Voulez-vous vraiment supprimer {{name}} ?" }, - "roles": { - "owner": "Propriétaire", - "admin": "Administrateur", - "viewer": "Lecteur", - "auditor": "Auditeur", - "employee": "Employé" - }, "actions": { "send": "Envoyer", "sendActivationMail": "Envoyer l’e-mail d’activation", @@ -2473,13 +2481,6 @@ "contractStartDate": "Date de début de contrat", "contractEndDate": "Date de fin de contrat" }, - "roles": { - "owner": "Propriétaire", - "admin": "Administrateur", - "viewer": "Lecteur", - "auditor": "Auditeur", - "employee": "Employé" - }, "roleDescriptions": { "owner": "Accès complet à tout", "admin": "Accès complet sauf la configuration de l’organisation et les clés API", diff --git a/apps/console/src/pages/iam/organizations/people/PeoplePage.tsx b/apps/console/src/pages/iam/organizations/people/PeoplePage.tsx index e8308488c..7bc3662ea 100644 --- a/apps/console/src/pages/iam/organizations/people/PeoplePage.tsx +++ b/apps/console/src/pages/iam/organizations/people/PeoplePage.tsx @@ -37,7 +37,7 @@ export const peoplePageQuery = graphql` ... on Organization { canCreateUser: permission(action: "iam:membership-profile:create", attributes: { target_role: "VIEWER" }) ...PeopleListFragment - @arguments(first: 20, order: { direction: ASC, field: FULL_NAME }) + @arguments(first: 100, order: { direction: ASC, field: FULL_NAME }) } } } @@ -55,7 +55,10 @@ export function PeoplePage(props: { ConnectionHandler.getConnectionID( organizationId, "PeopleListFragment_profiles", - { orderBy: { direction: "ASC", field: "FULL_NAME" } }, + { + orderBy: { direction: "ASC", field: "FULL_NAME" }, + filter: null, + }, ), ); diff --git a/apps/console/src/pages/iam/organizations/people/_components/PeopleList.tsx b/apps/console/src/pages/iam/organizations/people/_components/PeopleList.tsx index 2e8f13832..3b5a700b2 100644 --- a/apps/console/src/pages/iam/organizations/people/_components/PeopleList.tsx +++ b/apps/console/src/pages/iam/organizations/people/_components/PeopleList.tsx @@ -18,30 +18,51 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { getAssignableRoles } from "@probo/helpers"; -import { Tbody, Td, Th, Thead, Tr } from "@probo/ui"; +import { getAssignableRoles, getMembershipRoles, peopleRoles } from "@probo/helpers"; +import { + IconMagnifyingGlass, + Input, + Option, + Select, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@probo/ui"; import type { ComponentProps } from "react"; -import { use } from "react"; +import { use, useCallback, useEffect, useState, useTransition } from "react"; import { useTranslation } from "react-i18next"; -import { ConnectionHandler, graphql, usePaginationFragment } from "react-relay"; +import { graphql, usePaginationFragment } from "react-relay"; +import { useDebounceCallback } from "usehooks-ts"; import type { PeopleListFragment$key } from "#/__generated__/iam/PeopleListFragment.graphql"; -import type { PeopleListFragment_RefetchQuery } from "#/__generated__/iam/PeopleListFragment_RefetchQuery.graphql"; +import type { + MembershipRole, + PeopleListFragment_RefetchQuery, + ProfileOrderField, + ProfileState, +} from "#/__generated__/iam/PeopleListFragment_RefetchQuery.graphql"; import { type Order, SortableTable, SortableTh } from "#/components/SortableTable"; -import { useOrganizationId } from "#/hooks/useOrganizationId"; import { CurrentUser } from "#/providers/CurrentUser"; import { PeopleListItem } from "./PeopleListItem"; +const PAGE_SIZE = 100; +const SEARCH_DEBOUNCE_MS = 300; + +type PeopleKind = (typeof peopleRoles)[number]; + const fragment = graphql` fragment PeopleListFragment on Organization @refetchable(queryName: "PeopleListFragment_RefetchQuery") @argumentDefinitions( - first: { type: "Int", defaultValue: 20 } + first: { type: "Int", defaultValue: 100 } order: { type: "ProfileOrder" defaultValue: { direction: ASC, field: FULL_NAME } } + filter: { type: "ProfileFilter", defaultValue: null } after: { type: "CursorKey", defaultValue: null } before: { type: "CursorKey", defaultValue: null } last: { type: "Int", defaultValue: null } @@ -52,7 +73,8 @@ const fragment = graphql` last: $last before: $before orderBy: $order - ) @connection(key: "PeopleListFragment_profiles", filters: ["orderBy"]) @required(action: THROW) { + filter: $filter + ) @connection(key: "PeopleListFragment_profiles", filters: ["orderBy", "filter"]) @required(action: THROW) { __id totalCount edges @required(action: THROW) { @@ -65,76 +87,215 @@ const fragment = graphql` } `; +type PeopleFilter = { + query: string | null; + state: ProfileState | null; + role: MembershipRole | null; + kind: string | null; +}; + export function PeopleList(props: { fKey: PeopleListFragment$key; onConnectionIdChange: (connectionId: string) => void; }) { const { fKey, onConnectionIdChange } = props; - const organizationId = useOrganizationId(); const { t } = useTranslation(); const { role } = use(CurrentUser); const canManageRoles = getAssignableRoles(role).length > 0; + const [queryFilter, setQueryFilter] = useState(null); + const [stateFilter, setStateFilter] = useState(null); + const [roleFilter, setRoleFilter] = useState(null); + const [kindFilter, setKindFilter] = useState(null); + const [order, setOrder] = useState({ + direction: "ASC", + field: "FULL_NAME", + }); + const [isPending, startTransition] = useTransition(); + const peoplePagination = usePaginationFragment< PeopleListFragment_RefetchQuery, PeopleListFragment$key >(fragment, fKey); - const refetchPeople = () => { - peoplePagination.refetch({}, { fetchPolicy: "network-only" }); + const connectionId = peoplePagination.data.profiles.__id; + + useEffect(() => { + onConnectionIdChange(connectionId); + }, [connectionId, onConnectionIdChange]); + + const currentFilter = (overrides: Partial = {}): PeopleFilter => ({ + query: queryFilter, + state: stateFilter, + role: roleFilter, + kind: kindFilter, + ...overrides, + }); + + const connectionFilter = (filter: PeopleFilter) => ({ + query: filter.query, + state: filter.state, + role: filter.role, + kind: filter.kind, + contractEnded: null, + }); + + const refetchPeople = (overrides: Partial = {}, nextOrder: Order = order) => { + const filter = currentFilter(overrides); + startTransition(() => { + peoplePagination.refetch( + { + order: { + direction: nextOrder.direction, + field: nextOrder.field as ProfileOrderField, + }, + filter: connectionFilter(filter), + }, + { fetchPolicy: "network-only" }, + ); + }); }; - const handleOrderChange = (order: Order) => { - onConnectionIdChange( - ConnectionHandler.getConnectionID( - organizationId, - "PeopleListFragment_profiles", - { orderBy: order }, - ), - ); + const debouncedRefetchQuery = useDebounceCallback( + useCallback( + (value: string) => { + const newQuery = value === "" ? null : value; + startTransition(() => { + peoplePagination.refetch( + { + order: { + direction: order.direction, + field: order.field as ProfileOrderField, + }, + filter: { + query: newQuery, + state: stateFilter, + role: roleFilter, + kind: kindFilter, + contractEnded: null, + }, + }, + { fetchPolicy: "network-only" }, + ); + }); + }, + [peoplePagination, order, stateFilter, roleFilter, kindFilter], + ), + SEARCH_DEBOUNCE_MS, + ); + + const handleQueryFilterChange = (value: string) => { + setQueryFilter(value === "" ? null : value); + debouncedRefetchQuery(value); + }; + + const handleStateFilterChange = (value: string) => { + const newState = value === "ALL" ? null : (value as ProfileState); + setStateFilter(newState); + refetchPeople({ state: newState }); + }; + + const handleRoleFilterChange = (value: string) => { + const newRole = value === "ALL" ? null : (value as MembershipRole); + setRoleFilter(newRole); + refetchPeople({ role: newRole }); + }; + + const handleKindFilterChange = (value: string) => { + const newKind = value === "ALL" ? null : value; + setKindFilter(newKind); + refetchPeople({ kind: newKind }); + }; + + const handleOrderChange = (nextOrder: Order) => { + setOrder(nextOrder); + }; + + const refetchWithFilters: ComponentProps["refetch"] = ({ order: nextOrder }) => { + setOrder(nextOrder); + refetchPeople({}, nextOrder); }; return ( - ["refetch"] - } - pageSize={20} - > - - - {t("peopleList.columns.name")} - {t("peopleList.columns.status")} - {t("peopleList.columns.email")} - {canManageRoles && {t("peopleList.columns.role")}} - {t("peopleList.columns.createdOn")} - - - - - {peoplePagination.data.profiles.totalCount === 0 - ? ( - - - {t("peopleList.empty")} - - - ) - : ( - peoplePagination.data.profiles.edges.map(({ node: profile }) => ( - - )) - )} - - +
+
+ + + + +
+ +
+ + + + {t("peopleList.columns.name")} + {t("peopleList.columns.status")} + {t("peopleList.columns.email")} + {canManageRoles && {t("peopleList.columns.role")}} + {t("peopleList.columns.createdOn")} + + + + + {peoplePagination.data.profiles.totalCount === 0 + ? ( + + + {t("peopleList.empty")} + + + ) + : ( + peoplePagination.data.profiles.edges.map(({ node: profile }) => ( + refetchPeople()} + /> + )) + )} + + +
+
); } diff --git a/apps/console/src/pages/iam/organizations/people/_components/PeopleListItem.tsx b/apps/console/src/pages/iam/organizations/people/_components/PeopleListItem.tsx index a0c0411bc..1ce478908 100644 --- a/apps/console/src/pages/iam/organizations/people/_components/PeopleListItem.tsx +++ b/apps/console/src/pages/iam/organizations/people/_components/PeopleListItem.tsx @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { getAssignableRoles } from "@probo/helpers"; +import { getAssignableRoles, getMembershipRoles } from "@probo/helpers"; import { dateFormat } from "@probo/i18n"; import { ActionDropdown, @@ -288,21 +288,13 @@ export function PeopleListItem(props: { value={profile.membership.role} onValueChange={role => void handleUpdateRole(role)} > - {roleOptions.includes("OWNER") && ( - - )} - {roleOptions.includes("ADMIN") && ( - - )} - {roleOptions.includes("VIEWER") && ( - - )} - {roleOptions.includes("AUDITOR") && ( - - )} - {roleOptions.includes("EMPLOYEE") && ( - - )} + {getMembershipRoles(t) + .filter(({ value }) => roleOptions.includes(value)) + .map(({ value, label }) => ( + + ))} )} diff --git a/apps/console/src/pages/iam/organizations/people/_components/PersonForm.tsx b/apps/console/src/pages/iam/organizations/people/_components/PersonForm.tsx index bb425f708..d8db96ccc 100644 --- a/apps/console/src/pages/iam/organizations/people/_components/PersonForm.tsx +++ b/apps/console/src/pages/iam/organizations/people/_components/PersonForm.tsx @@ -18,8 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { formatDatetime, getAssignableRoles, peopleRoles } from "@probo/helpers"; -import { roles } from "@probo/helpers/src/roles"; +import { formatDatetime, getAssignableRoles, getMembershipRoles, peopleRoles, roles } from "@probo/helpers"; import { Button, Field, Input, Option } from "@probo/ui"; import { use } from "react"; import { useWatch } from "react-hook-form"; @@ -202,21 +201,13 @@ export function PersonForm(props: { label={t("personForm.fields.role")} disabled={disabled || !!id} > - {availableRoles.includes("OWNER") && ( - - )} - {availableRoles.includes("ADMIN") && ( - - )} - {availableRoles.includes("VIEWER") && ( - - )} - {availableRoles.includes("AUDITOR") && ( - - )} - {availableRoles.includes("EMPLOYEE") && ( - - )} + {getMembershipRoles(t) + .filter(({ value }) => availableRoles.includes(value)) + .map(({ value, label }) => ( + + ))}
diff --git a/packages/helpers/src/index.ts b/packages/helpers/src/index.ts index 76a749704..571bb4005 100644 --- a/packages/helpers/src/index.ts +++ b/packages/helpers/src/index.ts @@ -120,7 +120,13 @@ export { export { getTrackerTypeBadge, getTrackerSourceBadge } from "./tracker"; export { detectSocialName } from "./socialUrl"; export { formatError, type GraphQLError } from "./error"; -export { Role, roles, getAssignableRoles } from "./roles"; +export { + Role, + roles, + getAssignableRoles, + getMembershipRole, + getMembershipRoles, +} from "./roles"; export { getCompliancePortalDocumentAccessStatusBadgeVariant, getCompliancePortalDocumentAccessStatusLabel, diff --git a/packages/helpers/src/roles.ts b/packages/helpers/src/roles.ts index c46bcedca..62243b799 100644 --- a/packages/helpers/src/roles.ts +++ b/packages/helpers/src/roles.ts @@ -18,6 +18,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +type Translator = (s: string) => string; + export const Role = { OWNER: "OWNER", ADMIN: "ADMIN", @@ -46,3 +48,45 @@ export function getAssignableRoles(currentRole: Role): Role[] { return []; } + +export function getMembershipRoles(t: Translator) { + return [ + { + value: Role.OWNER, + label: t("helpers.membershipRole.owner"), + }, + { + value: Role.ADMIN, + label: t("helpers.membershipRole.admin"), + }, + { + value: Role.VIEWER, + label: t("helpers.membershipRole.viewer"), + }, + { + value: Role.AUDITOR, + label: t("helpers.membershipRole.auditor"), + }, + { + value: Role.EMPLOYEE, + label: t("helpers.membershipRole.employee"), + }, + ] as const; +} + +export function getMembershipRole(t: Translator, role?: string): string { + switch (role) { + case Role.OWNER: + return t("helpers.membershipRole.owner"); + case Role.ADMIN: + return t("helpers.membershipRole.admin"); + case Role.VIEWER: + return t("helpers.membershipRole.viewer"); + case Role.AUDITOR: + return t("helpers.membershipRole.auditor"); + case Role.EMPLOYEE: + return t("helpers.membershipRole.employee"); + default: + return t("helpers.common.unknown"); + } +} diff --git a/packages/n8n-node/nodes/Probo/actions/user/listUsers.operation.ts b/packages/n8n-node/nodes/Probo/actions/user/listUsers.operation.ts index 76883ee2a..a5865df8f 100644 --- a/packages/n8n-node/nodes/Probo/actions/user/listUsers.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/user/listUsers.operation.ts @@ -71,6 +71,77 @@ export const description: INodeProperties[] = [ default: 50, description: 'Max number of results to return', }, + { + displayName: 'Search', + name: 'query', + type: 'string', + displayOptions: { + show: { + resource: ['user'], + operation: ['listUsers'], + }, + }, + default: '', + description: 'Search users by full name or email address', + }, + { + displayName: 'State', + name: 'state', + type: 'options', + displayOptions: { + show: { + resource: ['user'], + operation: ['listUsers'], + }, + }, + options: [ + { name: 'Active', value: 'ACTIVE' }, + { name: 'All', value: '' }, + { name: 'Inactive', value: 'INACTIVE' }, + ], + default: '', + description: 'Filter by profile state', + }, + { + displayName: 'Role', + name: 'role', + type: 'options', + displayOptions: { + show: { + resource: ['user'], + operation: ['listUsers'], + }, + }, + options: [ + { name: 'Admin', value: 'ADMIN' }, + { name: 'All', value: '' }, + { name: 'Auditor', value: 'AUDITOR' }, + { name: 'Employee', value: 'EMPLOYEE' }, + { name: 'Owner', value: 'OWNER' }, + { name: 'Viewer', value: 'VIEWER' }, + ], + default: '', + description: 'Filter by membership role', + }, + { + displayName: 'Type', + name: 'kind', + type: 'options', + displayOptions: { + show: { + resource: ['user'], + operation: ['listUsers'], + }, + }, + options: [ + { name: 'All', value: '' }, + { name: 'Contractor', value: 'CONTRACTOR' }, + { name: 'Employee', value: 'EMPLOYEE' }, + { name: 'Service Account', value: 'SERVICE_ACCOUNT' }, + ], + default: '', + description: 'Filter by profile kind', + }, ]; export async function execute( @@ -80,12 +151,16 @@ export async function execute( const organizationId = this.getNodeParameter('organizationId', itemIndex) as string; const returnAll = this.getNodeParameter('returnAll', itemIndex) as boolean; const limit = this.getNodeParameter('limit', itemIndex, 50) as number; + const query = this.getNodeParameter('query', itemIndex, '') as string; + const state = this.getNodeParameter('state', itemIndex, '') as string; + const role = this.getNodeParameter('role', itemIndex, '') as string; + const kind = this.getNodeParameter('kind', itemIndex, '') as string; - const query = ` - query ListUsers($organizationId: ID!, $first: Int, $after: CursorKey, $orderBy: ProfileOrder) { + const gqlQuery = ` + query ListUsers($organizationId: ID!, $first: Int, $after: CursorKey, $orderBy: ProfileOrder, $filter: ProfileFilter) { node(id: $organizationId) { ... on Organization { - profiles(first: $first, after: $after, orderBy: $orderBy) { + profiles(first: $first, after: $after, orderBy: $orderBy, filter: $filter) { edges { node { id @@ -114,10 +189,27 @@ export async function execute( } `; + const filter: IDataObject = {}; + if (query) { + filter.query = query; + } + if (state) { + filter.state = state; + } + if (role) { + filter.role = role; + } + if (kind) { + filter.kind = kind; + } + const users = await proboConnectApiRequestAllItems.call( this, - query, - { organizationId }, + gqlQuery, + { + organizationId, + ...(Object.keys(filter).length > 0 ? { filter } : {}), + }, (response: IDataObject) => { const data = response?.data as IDataObject | undefined; const node = data?.node as IDataObject | undefined; diff --git a/pkg/cmd/user/list/list.go b/pkg/cmd/user/list/list.go index 7fc8e9156..1115b6ccb 100644 --- a/pkg/cmd/user/list/list.go +++ b/pkg/cmd/user/list/list.go @@ -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 diff --git a/pkg/coredata/membership_profile_filter.go b/pkg/coredata/membership_profile_filter.go index 1de3f2763..8d2d28f79 100644 --- a/pkg/coredata/membership_profile_filter.go +++ b/pkg/coredata/membership_profile_filter.go @@ -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 +) ` } diff --git a/pkg/server/api/connect/v1/graphql/organization.graphql b/pkg/server/api/connect/v1/graphql/organization.graphql index 7fecf35ff..cf1703b06 100644 --- a/pkg/server/api/connect/v1/graphql/organization.graphql +++ b/pkg/server/api/connect/v1/graphql/organization.graphql @@ -12,6 +12,7 @@ type Organization implements Node { last: Int before: CursorKey orderBy: ProfileOrder + filter: ProfileFilter ): ProfileConnection @goField(forceResolver: true) samlConfigurations( diff --git a/pkg/server/api/connect/v1/graphql/profile.graphql b/pkg/server/api/connect/v1/graphql/profile.graphql index fc1417868..94fd24d5b 100644 --- a/pkg/server/api/connect/v1/graphql/profile.graphql +++ b/pkg/server/api/connect/v1/graphql/profile.graphql @@ -71,6 +71,9 @@ enum ProfileOrderField input ProfileFilter { contractEnded: Boolean state: ProfileState + query: String + role: MembershipRole + kind: String } input ProfileOrder diff --git a/pkg/server/api/connect/v1/identity_resolvers.go b/pkg/server/api/connect/v1/identity_resolvers.go index 79bf2e3d5..b9f16d0ce 100644 --- a/pkg/server/api/connect/v1/identity_resolvers.go +++ b/pkg/server/api/connect/v1/identity_resolvers.go @@ -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) { diff --git a/pkg/server/api/connect/v1/organization_resolvers.go b/pkg/server/api/connect/v1/organization_resolvers.go index f31c9a842..16fe89e70 100644 --- a/pkg/server/api/connect/v1/organization_resolvers.go +++ b/pkg/server/api/connect/v1/organization_resolvers.go @@ -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. diff --git a/pkg/server/api/console/v1/graphql/organization.graphql b/pkg/server/api/console/v1/graphql/organization.graphql index b2e33799f..b35267706 100644 --- a/pkg/server/api/console/v1/graphql/organization.graphql +++ b/pkg/server/api/console/v1/graphql/organization.graphql @@ -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 diff --git a/pkg/server/api/console/v1/organization_resolvers.go b/pkg/server/api/console/v1/organization_resolvers.go index 5273679b2..f8d2546db 100644 --- a/pkg/server/api/console/v1/organization_resolvers.go +++ b/pkg/server/api/console/v1/organization_resolvers.go @@ -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]{ diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 6efcde65a..a78931425 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -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) diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 9bf5b3884..f8d00b817 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -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