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 } : {}),
};