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

@@ -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" }

View File

@@ -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",

View File

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

View File

@@ -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<string | null>(null);
const [stateFilter, setStateFilter] = useState<ProfileState | null>(null);
const [roleFilter, setRoleFilter] = useState<MembershipRole | null>(null);
const [kindFilter, setKindFilter] = useState<string | null>(null);
const [order, setOrder] = useState<Order>({
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> = {}): 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<PeopleFilter> = {}, 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<typeof SortableTable>["refetch"] = ({ order: nextOrder }) => {
setOrder(nextOrder);
refetchPeople({}, nextOrder);
};
return (
<SortableTable
{...peoplePagination}
refetch={
peoplePagination.refetch as ComponentProps<
typeof SortableTable
>["refetch"]
}
pageSize={20}
>
<Thead>
<Tr>
<SortableTh field="FULL_NAME" onOrderChange={handleOrderChange}>{t("peopleList.columns.name")}</SortableTh>
<SortableTh field="STATE">{t("peopleList.columns.status")}</SortableTh>
<SortableTh field="EMAIL_ADDRESS" onOrderChange={handleOrderChange}>{t("peopleList.columns.email")}</SortableTh>
{canManageRoles && <Th>{t("peopleList.columns.role")}</Th>}
<SortableTh field="CREATED_AT" onOrderChange={handleOrderChange}>{t("peopleList.columns.createdOn")}</SortableTh>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{peoplePagination.data.profiles.totalCount === 0
? (
<Tr>
<Td colSpan={7} className="text-center text-txt-secondary">
{t("peopleList.empty")}
</Td>
</Tr>
)
: (
peoplePagination.data.profiles.edges.map(({ node: profile }) => (
<PeopleListItem
connectionId={peoplePagination.data.profiles.__id}
key={profile.id}
fKey={profile}
onRefetch={refetchPeople}
/>
))
)}
</Tbody>
</SortableTable>
<div className="space-y-4">
<div className="flex items-center gap-4">
<Input
icon={IconMagnifyingGlass}
placeholder={t("peopleList.searchPlaceholder")}
value={queryFilter ?? ""}
onValueChange={handleQueryFilterChange}
/>
<Select
value={stateFilter ?? "ALL"}
onValueChange={handleStateFilterChange}
>
<Option value="ALL">{t("peopleList.filters.allStatuses")}</Option>
<Option value="ACTIVE">{t("peopleList.filters.active")}</Option>
<Option value="INACTIVE">{t("peopleList.filters.inactive")}</Option>
</Select>
<Select
value={roleFilter ?? "ALL"}
onValueChange={handleRoleFilterChange}
>
<Option value="ALL">{t("peopleList.filters.allRoles")}</Option>
{getMembershipRoles(t).map(({ value, label }) => (
<Option key={value} value={value}>
{label}
</Option>
))}
</Select>
<Select
value={kindFilter ?? "ALL"}
onValueChange={handleKindFilterChange}
>
<Option value="ALL">{t("peopleList.filters.allTypes")}</Option>
{peopleRoles.map((kind: PeopleKind) => (
<Option key={kind} value={kind}>
{t(`personForm.kinds.${kind}`)}
</Option>
))}
</Select>
</div>
<div className={isPending ? "opacity-50 pointer-events-none transition-opacity" : ""}>
<SortableTable
{...peoplePagination}
refetch={refetchWithFilters}
pageSize={PAGE_SIZE}
>
<Thead>
<Tr>
<SortableTh field="FULL_NAME" onOrderChange={handleOrderChange}>{t("peopleList.columns.name")}</SortableTh>
<SortableTh field="STATE">{t("peopleList.columns.status")}</SortableTh>
<SortableTh field="EMAIL_ADDRESS" onOrderChange={handleOrderChange}>{t("peopleList.columns.email")}</SortableTh>
{canManageRoles && <Th>{t("peopleList.columns.role")}</Th>}
<SortableTh field="CREATED_AT" onOrderChange={handleOrderChange}>{t("peopleList.columns.createdOn")}</SortableTh>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{peoplePagination.data.profiles.totalCount === 0
? (
<Tr>
<Td colSpan={7} className="text-center text-txt-secondary">
{t("peopleList.empty")}
</Td>
</Tr>
)
: (
peoplePagination.data.profiles.edges.map(({ node: profile }) => (
<PeopleListItem
connectionId={connectionId}
key={profile.id}
fKey={profile}
onRefetch={() => refetchPeople()}
/>
))
)}
</Tbody>
</SortableTable>
</div>
</div>
);
}

View File

@@ -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") && (
<Option value="OWNER">{t("peopleListItem.roles.owner")}</Option>
)}
{roleOptions.includes("ADMIN") && (
<Option value="ADMIN">{t("peopleListItem.roles.admin")}</Option>
)}
{roleOptions.includes("VIEWER") && (
<Option value="VIEWER">{t("peopleListItem.roles.viewer")}</Option>
)}
{roleOptions.includes("AUDITOR") && (
<Option value="AUDITOR">{t("peopleListItem.roles.auditor")}</Option>
)}
{roleOptions.includes("EMPLOYEE") && (
<Option value="EMPLOYEE">{t("peopleListItem.roles.employee")}</Option>
)}
{getMembershipRoles(t)
.filter(({ value }) => roleOptions.includes(value))
.map(({ value, label }) => (
<Option key={value} value={value}>
{label}
</Option>
))}
</Select>
</Td>
)}

View File

@@ -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") && (
<Option value="OWNER">{t("personForm.roles.owner")}</Option>
)}
{availableRoles.includes("ADMIN") && (
<Option value="ADMIN">{t("personForm.roles.admin")}</Option>
)}
{availableRoles.includes("VIEWER") && (
<Option value="VIEWER">{t("personForm.roles.viewer")}</Option>
)}
{availableRoles.includes("AUDITOR") && (
<Option value="AUDITOR">{t("personForm.roles.auditor")}</Option>
)}
{availableRoles.includes("EMPLOYEE") && (
<Option value="EMPLOYEE">{t("personForm.roles.employee")}</Option>
)}
{getMembershipRoles(t)
.filter(({ value }) => availableRoles.includes(value))
.map(({ value, label }) => (
<Option key={value} value={value}>
{label}
</Option>
))}
</ControlledField>
<div className="mt-4 space-y-2 text-sm text-txt-tertiary">