Split user remove and archive actions

Restore RemoveUser as a hard delete operation and surface dependency\nconflicts with a dedicated IAM error.\n\nAdd a new ArchiveUser flow that deactivates profiles while keeping the\nmember in the organization, then expose both actions across Connect, MCP,\nCLI, n8n, console UI, and e2e coverage.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
This commit is contained in:
Cursor Agent
2026-05-27 18:26:33 +00:00
committed by Bryan Frimin
parent a71a7bb56f
commit 1e08a23ddc
15 changed files with 628 additions and 48 deletions

View File

@@ -14,7 +14,7 @@
import { sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { ActionDropdown, Avatar, Badge, Breadcrumb, Card, DropdownItem, IconTrashCan, useConfirm } from "@probo/ui";
import { ActionDropdown, Avatar, Badge, Breadcrumb, Card, DropdownItem, IconArchive, IconTrashCan, useConfirm } from "@probo/ui";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { useNavigate } from "react-router";
import { graphql } from "relay-runtime";
@@ -34,6 +34,7 @@ export const personPageQuery = graphql`
fullName
emailAddress
source
state
canDelete: permission(action: "iam:membership-profile:delete")
...PersonFormFragment
}
@@ -51,6 +52,16 @@ const removeUserMutation = graphql`
}
`;
const archiveUserMutation = graphql`
mutation PersonPage_archiveMutation(
$input: ArchiveUserInput!
) {
archiveUser(input: $input) {
archivedProfileId
}
}
`;
export function PersonPage(props: { queryRef: PreloadedQuery<PersonPageQuery> }) {
const { queryRef } = props;
@@ -64,18 +75,25 @@ export function PersonPage(props: { queryRef: PreloadedQuery<PersonPageQuery> })
throw new Error("invalid type for node");
}
const [removeUser, isRemoving] = useMutationWithToasts(
removeUserMutation,
const [archiveUser, isArchiving] = useMutationWithToasts(
archiveUserMutation,
{
successMessage: __("Person archived successfully"),
errorMessage: __("Failed to archive person"),
},
);
const [removeUser, isRemoving] = useMutationWithToasts(
removeUserMutation,
{
successMessage: __("Person removed successfully"),
errorMessage: __("Failed to remove person"),
},
);
const handleRemove = () => {
const handleArchive = () => {
confirm(
() => {
return removeUser({
return archiveUser({
variables: {
input: {
profileId: person.id,
@@ -96,7 +114,32 @@ export function PersonPage(props: { queryRef: PreloadedQuery<PersonPageQuery> })
);
};
const canArchive = person.canDelete && person.source !== "SCIM";
const handleRemove = () => {
confirm(
() => {
return removeUser({
variables: {
input: {
profileId: person.id,
organizationId: organizationId,
},
},
onCompleted: () => {
void navigate(`/organizations/${organizationId}/people`);
},
});
},
{
message: sprintf(
__("Are you sure you want to remove %s?"),
person.fullName,
),
},
);
};
const canArchive = person.canDelete && person.source !== "SCIM" && person.state !== "INACTIVE";
const canRemove = person.canDelete && person.source !== "SCIM";
return (
<div className="space-y-6">
@@ -122,16 +165,27 @@ export function PersonPage(props: { queryRef: PreloadedQuery<PersonPageQuery> })
<div className="text-lg text-txt-secondary">{person.emailAddress}</div>
</div>
</div>
{canArchive && (
{(canArchive || canRemove) && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleRemove}
disabled={isRemoving}
>
{__("Archive")}
</DropdownItem>
{canArchive && (
<DropdownItem
icon={IconArchive}
onClick={handleArchive}
disabled={isArchiving}
>
{__("Archive")}
</DropdownItem>
)}
{canRemove && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleRemove}
disabled={isRemoving}
>
{__("Remove")}
</DropdownItem>
)}
</ActionDropdown>
)}
</div>

View File

@@ -18,6 +18,7 @@ import {
ActionDropdown,
Badge,
DropdownItem,
IconArchive,
IconMail,
IconTrashCan,
Option,
@@ -107,6 +108,14 @@ const removeUserMutation = graphql`
}
`;
const archiveUserMutation = graphql`
mutation PeopleListItem_archiveMutation($input: ArchiveUserInput!) {
archiveUser(input: $input) {
archivedProfileId
}
}
`;
export function PeopleListItem(props: {
connectionId: DataID;
fKey: PeopleListItemFragment$key;
@@ -128,6 +137,7 @@ export function PeopleListItem(props: {
const canSendActivationMail = isInactive && profile.source !== "SCIM" && profile.canInvite;
const canArchive = profile.canDelete && profile.source !== "SCIM" && profile.state !== "INACTIVE";
const canRemove = profile.canDelete && profile.source !== "SCIM";
const [inviteUser]
= useMutationWithToasts<PeopleListItem_inviteMutation>(inviteUserMutation, {
@@ -141,13 +151,21 @@ export function PeopleListItem(props: {
errorMessage: __("Failed to update role"),
},
);
const [removeUser, isRemoving] = useMutationWithToasts(
removeUserMutation,
const [archiveUser, isArchiving] = useMutationWithToasts(
archiveUserMutation,
{
successMessage: __("Person archived successfully"),
errorMessage: __("Failed to archive person"),
},
);
const [removeUser, isRemoving] = useMutationWithToasts(
removeUserMutation,
{
successMessage: __("Person removed successfully"),
errorMessage: __("Failed to remove person"),
},
);
const isMutating = isArchiving || isRemoving;
const handleInvite = () => {
confirm(
@@ -183,6 +201,29 @@ export function PeopleListItem(props: {
},
});
};
const handleArchive = () => {
confirm(
() => {
return archiveUser({
variables: {
input: {
profileId: profile.id,
organizationId: organizationId,
},
},
onCompleted: () => {
onRefetch();
},
});
},
{
message: sprintf(
__("Are you sure you want to archive %s?"),
profile.fullName,
),
},
);
};
const handleRemove = () => {
confirm(
() => {
@@ -201,7 +242,7 @@ export function PeopleListItem(props: {
},
{
message: sprintf(
__("Are you sure you want to archive %s?"),
__("Are you sure you want to remove %s?"),
profile.fullName,
),
},
@@ -211,7 +252,7 @@ export function PeopleListItem(props: {
return (
<Tr to={`/organizations/${organizationId}/people/${profile.id}`}>
<Td className={clsx(
isRemoving && "opacity-60 pointer-events-none",
isMutating && "opacity-60 pointer-events-none",
isInactive && "opacity-50",
)}
>
@@ -221,7 +262,7 @@ export function PeopleListItem(props: {
<Badge variant={profile.state === "INACTIVE" ? "neutral" : "success"}>{profile.state}</Badge>
</Td>
<Td className={clsx(
isRemoving && "opacity-60 pointer-events-none",
isMutating && "opacity-60 pointer-events-none",
isInactive && "opacity-50",
)}
>
@@ -235,7 +276,7 @@ export function PeopleListItem(props: {
noLink
className={clsx(
"pr-4",
isRemoving && "opacity-60 pointer-events-none",
isMutating && "opacity-60 pointer-events-none",
isInactive && "opacity-50",
)}
>
@@ -263,14 +304,14 @@ export function PeopleListItem(props: {
</Td>
)}
<Td className={clsx(
isRemoving && "opacity-60 pointer-events-none",
isMutating && "opacity-60 pointer-events-none",
isInactive && "opacity-50",
)}
>
{new Date(profile.createdAt).toLocaleDateString()}
</Td>
<Td noLink width={160} className="text-end">
{(canSendActivationMail || canArchive) && (
{(canSendActivationMail || canArchive || canRemove) && (
<ActionDropdown>
{canSendActivationMail && (
<DropdownItem
@@ -281,12 +322,20 @@ export function PeopleListItem(props: {
</DropdownItem>
)}
{canArchive && (
<DropdownItem
onClick={handleArchive}
icon={IconArchive}
>
{__("Archive person")}
</DropdownItem>
)}
{canRemove && (
<DropdownItem
onClick={handleRemove}
variant="danger"
icon={IconTrashCan}
>
{__("Archive person")}
{__("Remove person")}
</DropdownItem>
)}
</ActionDropdown>