Make profile state filters multi-value

Address PR review feedback on the profile-state split: SAML sign-in now
activates a pending profile, deactivation counts owners against the
profile's own organization to close a last-owner bypass, the migration
leaves historical activated_at/deactivated_at NULL rather than
fabricating timestamps, and pending members are no longer rendered with
the deactivated (faded) styling.

Drop the single-value state filter in favor of the multi-value states
across the profile and signatory surfaces. Remove ProfileFilter.state
(only states[] remains) and convert the signatures profileState filter
to profileStates. Turn the console people filter, the CLI
"user list --state" flag, and the n8n listUsers and getAllSignatures
state inputs into multi-select controls, where an empty selection means
all states.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-28 18:16:52 +02:00
parent b10fc55b7f
commit eecf8a1d97
29 changed files with 88 additions and 106 deletions

View File

@@ -1371,7 +1371,7 @@
"columns": { "name": "Name", "status": "Status", "email": "Email", "role": "Role", "createdOn": "Created on" },
"empty": "No people",
"searchPlaceholder": "Search people...",
"filters": { "allStatuses": "All statuses", "pending": "Pending", "active": "Active", "deactivated": "Deactivated", "allRoles": "All roles", "allTypes": "All types" }
"filters": { "allStatuses": "All statuses", "status": "Status", "pending": "Pending", "active": "Active", "deactivated": "Deactivated", "allRoles": "All roles", "allTypes": "All types" }
},
"peopleListItem": {
"messages": { "invitationSent": "Invitation sent successfully", "roleUpdated": "Role updated successfully", "deactivated": "Person deactivated successfully", "removed": "Person removed successfully" },

View File

@@ -2458,6 +2458,7 @@
"searchPlaceholder": "Rechercher des personnes...",
"filters": {
"allStatuses": "Tous les statuts",
"status": "Statut",
"pending": "En attente",
"active": "Actif",
"deactivated": "Désactivé",

View File

@@ -43,7 +43,7 @@ export const enrollDevicePageQuery = graphql`
profiles(
first: 1000
orderBy: { direction: ASC, field: ORGANIZATION_NAME }
filter: { state: ACTIVE }
filter: { states: [ACTIVE] }
) @required(action: THROW) {
edges @required(action: THROW) {
node @required(action: THROW) {

View File

@@ -41,7 +41,7 @@ export const membershipsPageQuery = graphql`
profiles(
first: 1000
orderBy: { direction: ASC, field: ORGANIZATION_NAME }
filter: { state: ACTIVE }
filter: { states: [ACTIVE] }
)
@connection(key: "MembershipsPage_profiles")
@required(action: THROW) {

View File

@@ -34,7 +34,7 @@ export const membershipsDropdownMenuQuery = graphql`
profiles(
first: 1000
orderBy: { direction: ASC, field: ORGANIZATION_NAME }
filter: { state: ACTIVE }
filter: { states: [ACTIVE] }
) @required(action: THROW) {
edges @required(action: THROW) {
node @required(action: THROW) {

View File

@@ -20,6 +20,7 @@
import { getAssignableRoles, getMembershipRoles, peopleRoles } from "@probo/helpers";
import {
Checkbox,
IconMagnifyingGlass,
Input,
Option,
@@ -89,11 +90,13 @@ const fragment = graphql`
type PeopleFilter = {
query: string | null;
state: ProfileState | null;
states: ProfileState[];
role: MembershipRole | null;
kind: string | null;
};
const PROFILE_STATES: ProfileState[] = ["PENDING", "ACTIVE", "DEACTIVATED"];
export function PeopleList(props: {
fKey: PeopleListFragment$key;
onConnectionIdChange: (connectionId: string) => void;
@@ -105,7 +108,7 @@ export function PeopleList(props: {
const canManageRoles = getAssignableRoles(role).length > 0;
const [queryFilter, setQueryFilter] = useState<string | null>(null);
const [stateFilter, setStateFilter] = useState<ProfileState | null>(null);
const [statesFilter, setStatesFilter] = useState<ProfileState[]>([]);
const [roleFilter, setRoleFilter] = useState<MembershipRole | null>(null);
const [kindFilter, setKindFilter] = useState<string | null>(null);
const [order, setOrder] = useState<Order>({
@@ -127,7 +130,7 @@ export function PeopleList(props: {
const currentFilter = (overrides: Partial<PeopleFilter> = {}): PeopleFilter => ({
query: queryFilter,
state: stateFilter,
states: statesFilter,
role: roleFilter,
kind: kindFilter,
...overrides,
@@ -135,7 +138,7 @@ export function PeopleList(props: {
const connectionFilter = (filter: PeopleFilter) => ({
query: filter.query,
state: filter.state,
states: filter.states.length > 0 ? filter.states : null,
role: filter.role,
kind: filter.kind,
contractEnded: null,
@@ -170,7 +173,7 @@ export function PeopleList(props: {
},
filter: {
query: newQuery,
state: stateFilter,
states: statesFilter.length > 0 ? statesFilter : null,
role: roleFilter,
kind: kindFilter,
contractEnded: null,
@@ -180,7 +183,7 @@ export function PeopleList(props: {
);
});
},
[peoplePagination, order, stateFilter, roleFilter, kindFilter],
[peoplePagination, order, statesFilter, roleFilter, kindFilter],
),
SEARCH_DEBOUNCE_MS,
);
@@ -190,10 +193,12 @@ export function PeopleList(props: {
debouncedRefetchQuery(value);
};
const handleStateFilterChange = (value: string) => {
const newState = value === "ALL" ? null : (value as ProfileState);
setStateFilter(newState);
refetchPeople({ state: newState });
const handleStateFilterToggle = (state: ProfileState) => {
const newStates = statesFilter.includes(state)
? statesFilter.filter(s => s !== state)
: [...statesFilter, state];
setStatesFilter(newStates);
refetchPeople({ states: newStates });
};
const handleRoleFilterChange = (value: string) => {
@@ -226,15 +231,18 @@ export function PeopleList(props: {
value={queryFilter ?? ""}
onValueChange={handleQueryFilterChange}
/>
<Select
value={stateFilter ?? "ALL"}
onValueChange={handleStateFilterChange}
>
<Option value="ALL">{t("peopleList.filters.allStatuses")}</Option>
<Option value="PENDING">{t("peopleList.filters.pending")}</Option>
<Option value="ACTIVE">{t("peopleList.filters.active")}</Option>
<Option value="DEACTIVATED">{t("peopleList.filters.deactivated")}</Option>
</Select>
<div className="flex items-center gap-3">
<span className="text-sm text-txt-secondary">{t("peopleList.filters.status")}</span>
{PROFILE_STATES.map(state => (
<label key={state} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={statesFilter.includes(state)}
onChange={() => handleStateFilterToggle(state)}
/>
{t(`peopleList.filters.${state.toLowerCase()}`)}
</label>
))}
</div>
<Select
value={roleFilter ?? "ALL"}
onValueChange={handleRoleFilterChange}

View File

@@ -146,7 +146,7 @@ export function PeopleListItem(props: {
: [...availableRoles, profile.membership.role];
const isActive = profile.state === "ACTIVE";
const isInactive = !isActive;
const isInactive = profile.state === "DEACTIVATED";
const canSendActivationMail = !isActive && profile.source !== "SCIM" && profile.canInvite;
const canDeactivate = profile.canDeactivate && profile.source !== "SCIM" && profile.state !== "DEACTIVATED";

View File

@@ -46,10 +46,10 @@ export const documentLayoutQuery = graphql`
...DocumentTitleFormFragment
...DocumentActionsDropdown_versionFragment
...DocumentDetailsCard_versionFragment
signatures(first: 0 filter: { activeContract: true, profileState: ACTIVE }) {
signatures(first: 0 filter: { activeContract: true, profileStates: [ACTIVE] }) {
totalCount
}
signedSignatures: signatures(first: 0 filter: { states: [SIGNED], activeContract: true, profileState: ACTIVE }) {
signedSignatures: signatures(first: 0 filter: { states: [SIGNED], activeContract: true, profileStates: [ACTIVE] }) {
totalCount
}
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
@@ -91,10 +91,10 @@ export const documentLayoutQuery = graphql`
...DocumentTitleFormFragment
...DocumentActionsDropdown_versionFragment
...DocumentDetailsCard_versionFragment
signatures(first: 0 filter: { activeContract: true, profileState: ACTIVE }) {
signatures(first: 0 filter: { activeContract: true, profileStates: [ACTIVE] }) {
totalCount
}
signedSignatures: signatures(first: 0 filter: { states: [SIGNED], activeContract: true, profileState: ACTIVE }) {
signedSignatures: signatures(first: 0 filter: { states: [SIGNED], activeContract: true, profileStates: [ACTIVE] }) {
totalCount
}
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {

View File

@@ -79,10 +79,10 @@ const fragment = graphql`
}
}
}
signatures(first: 0 filter: { activeContract: true, profileState: ACTIVE }) {
signatures(first: 0 filter: { activeContract: true, profileStates: [ACTIVE] }) {
totalCount
}
signedSignatures: signatures(first: 0 filter: { states: [SIGNED], activeContract: true, profileState: ACTIVE }) {
signedSignatures: signatures(first: 0 filter: { states: [SIGNED], activeContract: true, profileStates: [ACTIVE] }) {
totalCount
}
}

View File

@@ -210,7 +210,7 @@ function PeopleList({
signatureDocumentsDialogPeopleQuery,
{
organizationId,
filter: { contractEnded: false, state: "ACTIVE" },
filter: { contractEnded: false, states: ["ACTIVE"] },
},
);
const {

View File

@@ -33,7 +33,7 @@ export const documentSignaturesPageQuery = graphql`
query DocumentSignaturesPageQuery($documentId: ID! $organizationId: ID! $versionId: ID! $versionSpecified: Boolean!) {
organization: node(id: $organizationId) {
__typename
...DocumentSignatureList_peopleFragment @arguments(filter: { contractEnded: false, state: ACTIVE })
...DocumentSignatureList_peopleFragment @arguments(filter: { contractEnded: false, states: [ACTIVE] })
}
# We use this on /documents/:documentId
document: node(id: $documentId) @skip(if: $versionSpecified) {

View File

@@ -35,7 +35,7 @@ const versionFragment = graphql`
@argumentDefinitions(
count: { type: "Int", defaultValue: 1000 }
cursor: { type: "CursorKey" }
signatureFilter: { type: "DocumentVersionSignatureFilter", defaultValue: { activeContract: true, profileState: ACTIVE } }
signatureFilter: { type: "DocumentVersionSignatureFilter", defaultValue: { activeContract: true, profileStates: [ACTIVE] } }
) {
...DocumentSignaturePlaceholder_versionFragment
signatures(first: $count, after: $cursor, filter: $signatureFilter)
@@ -110,7 +110,7 @@ export function DocumentSignatureList(props: {
const filter = {
activeContract: true,
profileState: "ACTIVE" as const,
profileStates: ["ACTIVE" as const],
...(selectedStates.length > 0 ? { states: selectedStates } : {}),
};

View File

@@ -98,13 +98,12 @@ export const description: INodeProperties[] = [
description: 'Whether to filter by active contract status',
},
{
displayName: 'Profile State',
displayName: 'Profile States',
name: 'state',
type: 'options',
default: '',
description: 'Filter by signatory profile state',
type: 'multiOptions',
default: [],
description: 'Filter by signatory profile states',
options: [
{ name: 'Any', value: '' },
{ name: 'Pending', value: 'PENDING' },
{ name: 'Active', value: 'ACTIVE' },
{ name: 'Deactivated', value: 'DEACTIVATED' },
@@ -126,7 +125,7 @@ export async function execute(
const filter: IDataObject = {};
if ((filters.states as string[])?.length) filter.states = filters.states;
if (filters.activeContract !== undefined) filter.activeContract = filters.activeContract;
if (filters.state) filter.profileState = filters.state;
if ((filters.state as string[])?.length) filter.profileStates = filters.state;
const hasFilter = Object.keys(filter).length > 0;

View File

@@ -85,9 +85,9 @@ export const description: INodeProperties[] = [
description: 'Search users by full name or email address',
},
{
displayName: 'State',
displayName: 'States',
name: 'state',
type: 'options',
type: 'multiOptions',
displayOptions: {
show: {
resource: ['user'],
@@ -96,12 +96,11 @@ export const description: INodeProperties[] = [
},
options: [
{ name: 'Active', value: 'ACTIVE' },
{ name: 'All', value: '' },
{ name: 'Deactivated', value: 'DEACTIVATED' },
{ name: 'Pending', value: 'PENDING' },
],
default: '',
description: 'Filter by profile state',
default: [],
description: 'Filter by profile states',
},
{
displayName: 'Role',
@@ -153,7 +152,7 @@ export async function execute(
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 state = this.getNodeParameter('state', itemIndex, []) as string[];
const role = this.getNodeParameter('role', itemIndex, '') as string;
const kind = this.getNodeParameter('kind', itemIndex, '') as string;
@@ -194,8 +193,8 @@ export async function execute(
if (query) {
filter.query = query;
}
if (state) {
filter.state = state;
if (state.length > 0) {
filter.states = state;
}
if (role) {
filter.role = role;

View File

@@ -72,7 +72,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
flagOrder string
flagOrderDir string
flagContractEnded string
flagState string
flagState []string
flagFilter string
flagRole string
flagKind string
@@ -157,12 +157,14 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
filter["contractEnded"] = flagContractEnded == "true"
}
if flagState != "" {
if err := cmdutil.ValidateEnum("state", flagState, []string{"PENDING", "ACTIVE", "DEACTIVATED"}); err != nil {
return err
if len(flagState) > 0 {
for _, state := range flagState {
if err := cmdutil.ValidateEnum("state", state, []string{"PENDING", "ACTIVE", "DEACTIVATED"}); err != nil {
return err
}
}
filter["state"] = flagState
filter["states"] = flagState
}
if flagFilter != "" {
@@ -273,7 +275,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().StringVar(&flagOrder, "order-by", "", "Order by field (FULL_NAME, CREATED_AT, KIND)")
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 (PENDING, ACTIVE, DEACTIVATED)")
cmd.Flags().StringSliceVar(&flagState, "state", nil, "Filter by profile state; repeat or comma-separate for multiple (PENDING, ACTIVE, DEACTIVATED)")
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)")

View File

@@ -28,15 +28,15 @@ type (
DocumentVersionSignatureFilter struct {
states DocumentVersionSignatureStates
activeContract *bool
profileState *ProfileState
profileStates ProfileStateValues
}
)
func NewDocumentVersionSignatureFilter(states []DocumentVersionSignatureState, activeContract *bool, profileState *ProfileState) *DocumentVersionSignatureFilter {
func NewDocumentVersionSignatureFilter(states []DocumentVersionSignatureState, activeContract *bool, profileStates []ProfileState) *DocumentVersionSignatureFilter {
return &DocumentVersionSignatureFilter{
states: DocumentVersionSignatureStates(states),
activeContract: activeContract,
profileState: profileState,
profileStates: ProfileStateValues(profileStates),
}
}
@@ -44,7 +44,7 @@ func (f *DocumentVersionSignatureFilter) SQLArguments() pgx.StrictNamedArgs {
return pgx.StrictNamedArgs{
"states": f.states,
"active_contract": f.activeContract,
"profile_state": f.profileState,
"profile_states": f.profileStates,
}
}
@@ -81,13 +81,13 @@ func (f *DocumentVersionSignatureFilter) SQLFragment() string {
END
AND
CASE
WHEN @profile_state::text IS NULL
WHEN @profile_states::membership_state[] IS NULL
THEN TRUE
ELSE EXISTS (
SELECT 1
FROM iam_membership_profiles p
WHERE p.id = signed_by_profile_id
AND p.state = @profile_state::membership_state
AND p.state = ANY(@profile_states::membership_state[])
)
END
)`

View File

@@ -81,11 +81,6 @@ func (f *MembershipProfileFilter) WithExternalID(externalID string) *MembershipP
return f
}
func (f *MembershipProfileFilter) WithState(state ProfileState) *MembershipProfileFilter {
f.states = ProfileStateValues{state}
return f
}
func (f *MembershipProfileFilter) WithStates(states ...ProfileState) *MembershipProfileFilter {
f.states = ProfileStateValues(states)
return f

View File

@@ -46,10 +46,3 @@ WHERE state = 'DEACTIVATED'
)
);
UPDATE iam_membership_profiles
SET activated_at = updated_at
WHERE state = 'ACTIVE';
UPDATE iam_membership_profiles
SET deactivated_at = NOW()
WHERE state = 'DEACTIVATED';

View File

@@ -405,7 +405,7 @@ func (s *OrganizationService) DeactivateUser(
if membership.Role == coredata.MembershipRoleOwner && profile.State == coredata.ProfileStateActive {
profiles := coredata.MembershipProfiles{}
count, err := profiles.CountActiveOwnerByOrganizationID(ctx, tx, scope, organizationID)
count, err := profiles.CountActiveOwnerByOrganizationID(ctx, tx, scope, profile.OrganizationID)
if err != nil {
return fmt.Errorf("cannot count active owners: %w", err)
}

View File

@@ -336,6 +336,10 @@ func (s *Service) HandleAssertion(
if profile.State == coredata.ProfileStateDeactivated {
return NewUserInactiveError(profile.ID)
}
if profile.State == coredata.ProfileStatePending {
profile.MarkActive(now)
}
}
if err := membership.LoadByIdentityIDAndOrganizationID(

View File

@@ -71,7 +71,6 @@ enum ProfileOrderField
input ProfileFilter {
contractEnded: Boolean
state: ProfileState
states: [ProfileState!]
query: String
role: MembershipRole

View File

@@ -36,10 +36,6 @@ func (r *identityResolver) Profiles(ctx context.Context, obj *types.Identity, fi
filters.WithStates(filter.States...)
}
if filter.State != nil {
filters.WithState(*filter.State)
}
if filter.Query != nil {
filters.WithQuery(filter.Query)
}

View File

@@ -192,10 +192,6 @@ func (r *organizationResolver) Profiles(ctx context.Context, obj *types.Organiza
filters.WithStates(filter.States...)
}
if filter.State != nil {
filters.WithState(*filter.State)
}
if filter.Query != nil {
filters.WithQuery(filter.Query)
}

View File

@@ -282,7 +282,7 @@ func (r *documentVersionResolver) Signatures(ctx context.Context, obj *types.Doc
var (
signatureStates []coredata.DocumentVersionSignatureState
activeContract *bool
profileState *coredata.ProfileState
profileStates []coredata.ProfileState
)
if filter != nil {
@@ -294,12 +294,12 @@ func (r *documentVersionResolver) Signatures(ctx context.Context, obj *types.Doc
activeContract = filter.ActiveContract
}
if filter.ProfileState != nil {
profileState = filter.ProfileState
if filter.ProfileStates != nil {
profileStates = filter.ProfileStates
}
}
signatureFilter := coredata.NewDocumentVersionSignatureFilter(signatureStates, activeContract, profileState)
signatureFilter := coredata.NewDocumentVersionSignatureFilter(signatureStates, activeContract, profileStates)
cursor := types.NewCursor(first, after, last, before, pageOrderBy)

View File

@@ -247,7 +247,7 @@ input DocumentVersionSignatureOrder {
input DocumentVersionSignatureFilter {
states: [DocumentVersionSignatureState!]
activeContract: Boolean
profileState: ProfileState
profileStates: [ProfileState!]
}
input DocumentVersionApprovalQuorumOrder {

View File

@@ -69,7 +69,6 @@ input ProfileOrder
input ProfileFilter {
contractEnded: Boolean
state: ProfileState
states: [ProfileState!]
query: String
role: MembershipRole

View File

@@ -123,10 +123,6 @@ func (r *organizationResolver) Profiles(ctx context.Context, obj *types.Organiza
filters.WithStates(filter.States...)
}
if filter.State != nil {
filters.WithState(*filter.State)
}
if filter.Query != nil {
filters.WithQuery(filter.Query)
}

View File

@@ -2401,7 +2401,7 @@ func (r *Resolver) ListDocumentVersionSignaturesTool(ctx context.Context, req *m
var (
signatureStates []coredata.DocumentVersionSignatureState
activeContract *bool
profileState *coredata.ProfileState
profileStates []coredata.ProfileState
)
if input.Filter != nil {
@@ -2413,12 +2413,12 @@ func (r *Resolver) ListDocumentVersionSignaturesTool(ctx context.Context, req *m
activeContract = input.Filter.ActiveContract
}
if input.Filter.ProfileState != nil {
profileState = input.Filter.ProfileState
if input.Filter.ProfileStates != nil {
profileStates = input.Filter.ProfileStates
}
}
signatureFilter := coredata.NewDocumentVersionSignatureFilter(signatureStates, activeContract, profileState)
signatureFilter := coredata.NewDocumentVersionSignatureFilter(signatureStates, activeContract, profileStates)
page, err := prb.Documents.ListSignatures(ctx, scope, input.DocumentVersionID, cursor, signatureFilter)
if err != nil {
@@ -2780,10 +2780,6 @@ func (r *Resolver) ListUsersTool(ctx context.Context, req *mcp.CallToolRequest,
filter.WithStates(input.Filter.States...)
}
if input.Filter.State != nil {
filter.WithState(*input.Filter.State)
}
if input.Filter.Query != nil {
filter.WithQuery(input.Filter.Query)
}

View File

@@ -577,9 +577,6 @@ components:
contract_ended:
type: boolean
description: Filter by contract status. True returns only users with ended contracts, false returns only users with active or no contract.
state:
$ref: "#/components/schemas/ProfileState"
description: Filter by profile state (PENDING, ACTIVE, or DEACTIVATED)
states:
type: array
items:
@@ -6421,9 +6418,11 @@ components:
active_contract:
type: boolean
description: Signatory contract status
profile_state:
$ref: "#/components/schemas/ProfileState"
description: Signatory profile state
profile_states:
type: array
items:
$ref: "#/components/schemas/ProfileState"
description: Signatory profile states
ListDocumentVersionSignaturesOutput:
type: object