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:
committed by
Bryan Frimin
parent
a71a7bb56f
commit
1e08a23ddc
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
import { sprintf } from "@probo/helpers";
|
import { sprintf } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
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 { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
@@ -34,6 +34,7 @@ export const personPageQuery = graphql`
|
|||||||
fullName
|
fullName
|
||||||
emailAddress
|
emailAddress
|
||||||
source
|
source
|
||||||
|
state
|
||||||
canDelete: permission(action: "iam:membership-profile:delete")
|
canDelete: permission(action: "iam:membership-profile:delete")
|
||||||
...PersonFormFragment
|
...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> }) {
|
export function PersonPage(props: { queryRef: PreloadedQuery<PersonPageQuery> }) {
|
||||||
const { queryRef } = props;
|
const { queryRef } = props;
|
||||||
|
|
||||||
@@ -64,18 +75,25 @@ export function PersonPage(props: { queryRef: PreloadedQuery<PersonPageQuery> })
|
|||||||
throw new Error("invalid type for node");
|
throw new Error("invalid type for node");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [removeUser, isRemoving] = useMutationWithToasts(
|
const [archiveUser, isArchiving] = useMutationWithToasts(
|
||||||
removeUserMutation,
|
archiveUserMutation,
|
||||||
{
|
{
|
||||||
successMessage: __("Person archived successfully"),
|
successMessage: __("Person archived successfully"),
|
||||||
errorMessage: __("Failed to archive person"),
|
errorMessage: __("Failed to archive person"),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const [removeUser, isRemoving] = useMutationWithToasts(
|
||||||
|
removeUserMutation,
|
||||||
|
{
|
||||||
|
successMessage: __("Person removed successfully"),
|
||||||
|
errorMessage: __("Failed to remove person"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const handleRemove = () => {
|
const handleArchive = () => {
|
||||||
confirm(
|
confirm(
|
||||||
() => {
|
() => {
|
||||||
return removeUser({
|
return archiveUser({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
profileId: person.id,
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<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 className="text-lg text-txt-secondary">{person.emailAddress}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{canArchive && (
|
{(canArchive || canRemove) && (
|
||||||
<ActionDropdown variant="secondary">
|
<ActionDropdown variant="secondary">
|
||||||
<DropdownItem
|
{canArchive && (
|
||||||
variant="danger"
|
<DropdownItem
|
||||||
icon={IconTrashCan}
|
icon={IconArchive}
|
||||||
onClick={handleRemove}
|
onClick={handleArchive}
|
||||||
disabled={isRemoving}
|
disabled={isArchiving}
|
||||||
>
|
>
|
||||||
{__("Archive")}
|
{__("Archive")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
{canRemove && (
|
||||||
|
<DropdownItem
|
||||||
|
variant="danger"
|
||||||
|
icon={IconTrashCan}
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={isRemoving}
|
||||||
|
>
|
||||||
|
{__("Remove")}
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
</ActionDropdown>
|
</ActionDropdown>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ActionDropdown,
|
ActionDropdown,
|
||||||
Badge,
|
Badge,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
|
IconArchive,
|
||||||
IconMail,
|
IconMail,
|
||||||
IconTrashCan,
|
IconTrashCan,
|
||||||
Option,
|
Option,
|
||||||
@@ -107,6 +108,14 @@ const removeUserMutation = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const archiveUserMutation = graphql`
|
||||||
|
mutation PeopleListItem_archiveMutation($input: ArchiveUserInput!) {
|
||||||
|
archiveUser(input: $input) {
|
||||||
|
archivedProfileId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export function PeopleListItem(props: {
|
export function PeopleListItem(props: {
|
||||||
connectionId: DataID;
|
connectionId: DataID;
|
||||||
fKey: PeopleListItemFragment$key;
|
fKey: PeopleListItemFragment$key;
|
||||||
@@ -128,6 +137,7 @@ export function PeopleListItem(props: {
|
|||||||
|
|
||||||
const canSendActivationMail = isInactive && profile.source !== "SCIM" && profile.canInvite;
|
const canSendActivationMail = isInactive && profile.source !== "SCIM" && profile.canInvite;
|
||||||
const canArchive = profile.canDelete && profile.source !== "SCIM" && profile.state !== "INACTIVE";
|
const canArchive = profile.canDelete && profile.source !== "SCIM" && profile.state !== "INACTIVE";
|
||||||
|
const canRemove = profile.canDelete && profile.source !== "SCIM";
|
||||||
|
|
||||||
const [inviteUser]
|
const [inviteUser]
|
||||||
= useMutationWithToasts<PeopleListItem_inviteMutation>(inviteUserMutation, {
|
= useMutationWithToasts<PeopleListItem_inviteMutation>(inviteUserMutation, {
|
||||||
@@ -141,13 +151,21 @@ export function PeopleListItem(props: {
|
|||||||
errorMessage: __("Failed to update role"),
|
errorMessage: __("Failed to update role"),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const [removeUser, isRemoving] = useMutationWithToasts(
|
const [archiveUser, isArchiving] = useMutationWithToasts(
|
||||||
removeUserMutation,
|
archiveUserMutation,
|
||||||
{
|
{
|
||||||
successMessage: __("Person archived successfully"),
|
successMessage: __("Person archived successfully"),
|
||||||
errorMessage: __("Failed to archive person"),
|
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 = () => {
|
const handleInvite = () => {
|
||||||
confirm(
|
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 = () => {
|
const handleRemove = () => {
|
||||||
confirm(
|
confirm(
|
||||||
() => {
|
() => {
|
||||||
@@ -201,7 +242,7 @@ export function PeopleListItem(props: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: sprintf(
|
message: sprintf(
|
||||||
__("Are you sure you want to archive %s?"),
|
__("Are you sure you want to remove %s?"),
|
||||||
profile.fullName,
|
profile.fullName,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -211,7 +252,7 @@ export function PeopleListItem(props: {
|
|||||||
return (
|
return (
|
||||||
<Tr to={`/organizations/${organizationId}/people/${profile.id}`}>
|
<Tr to={`/organizations/${organizationId}/people/${profile.id}`}>
|
||||||
<Td className={clsx(
|
<Td className={clsx(
|
||||||
isRemoving && "opacity-60 pointer-events-none",
|
isMutating && "opacity-60 pointer-events-none",
|
||||||
isInactive && "opacity-50",
|
isInactive && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -221,7 +262,7 @@ export function PeopleListItem(props: {
|
|||||||
<Badge variant={profile.state === "INACTIVE" ? "neutral" : "success"}>{profile.state}</Badge>
|
<Badge variant={profile.state === "INACTIVE" ? "neutral" : "success"}>{profile.state}</Badge>
|
||||||
</Td>
|
</Td>
|
||||||
<Td className={clsx(
|
<Td className={clsx(
|
||||||
isRemoving && "opacity-60 pointer-events-none",
|
isMutating && "opacity-60 pointer-events-none",
|
||||||
isInactive && "opacity-50",
|
isInactive && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -235,7 +276,7 @@ export function PeopleListItem(props: {
|
|||||||
noLink
|
noLink
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"pr-4",
|
"pr-4",
|
||||||
isRemoving && "opacity-60 pointer-events-none",
|
isMutating && "opacity-60 pointer-events-none",
|
||||||
isInactive && "opacity-50",
|
isInactive && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -263,14 +304,14 @@ export function PeopleListItem(props: {
|
|||||||
</Td>
|
</Td>
|
||||||
)}
|
)}
|
||||||
<Td className={clsx(
|
<Td className={clsx(
|
||||||
isRemoving && "opacity-60 pointer-events-none",
|
isMutating && "opacity-60 pointer-events-none",
|
||||||
isInactive && "opacity-50",
|
isInactive && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{new Date(profile.createdAt).toLocaleDateString()}
|
{new Date(profile.createdAt).toLocaleDateString()}
|
||||||
</Td>
|
</Td>
|
||||||
<Td noLink width={160} className="text-end">
|
<Td noLink width={160} className="text-end">
|
||||||
{(canSendActivationMail || canArchive) && (
|
{(canSendActivationMail || canArchive || canRemove) && (
|
||||||
<ActionDropdown>
|
<ActionDropdown>
|
||||||
{canSendActivationMail && (
|
{canSendActivationMail && (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
@@ -281,12 +322,20 @@ export function PeopleListItem(props: {
|
|||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
)}
|
)}
|
||||||
{canArchive && (
|
{canArchive && (
|
||||||
|
<DropdownItem
|
||||||
|
onClick={handleArchive}
|
||||||
|
icon={IconArchive}
|
||||||
|
>
|
||||||
|
{__("Archive person")}
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
{canRemove && (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
onClick={handleRemove}
|
onClick={handleRemove}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
icon={IconTrashCan}
|
icon={IconTrashCan}
|
||||||
>
|
>
|
||||||
{__("Archive person")}
|
{__("Remove person")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
)}
|
)}
|
||||||
</ActionDropdown>
|
</ActionDropdown>
|
||||||
|
|||||||
@@ -201,23 +201,124 @@ func TestUser_RemoveUser(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, userID, mutationResult.RemoveUser.DeletedProfileID)
|
assert.Equal(t, userID, mutationResult.RemoveUser.DeletedProfileID)
|
||||||
|
|
||||||
// Remove archives the user instead of hard-deleting them.
|
// Removed user should no longer be returned.
|
||||||
err = owner.ExecuteConnect(query, map[string]any{
|
err = owner.ExecuteConnect(query, map[string]any{
|
||||||
"id": owner.GetOrganizationID().String(),
|
"id": owner.GetOrganizationID().String(),
|
||||||
}, &result)
|
}, &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var removedUserState string
|
var removedUserFound bool
|
||||||
|
|
||||||
for _, edge := range result.Node.Profiles.Edges {
|
for _, edge := range result.Node.Profiles.Edges {
|
||||||
if edge.Node.ID == userID {
|
if edge.Node.ID == userID {
|
||||||
removedUserState = edge.Node.State
|
removedUserFound = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NotEmpty(t, removedUserState, "Should still find archived user")
|
assert.False(t, removedUserFound, "Should not find removed user")
|
||||||
assert.Equal(t, "INACTIVE", removedUserState)
|
}
|
||||||
|
|
||||||
|
func TestUser_ArchiveUser(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
|
// Create a user to archive.
|
||||||
|
userToArchive := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||||
|
_ = userToArchive
|
||||||
|
|
||||||
|
query := `
|
||||||
|
query($id: ID!) {
|
||||||
|
node(id: $id) {
|
||||||
|
... on Organization {
|
||||||
|
profiles(first: 50) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
state
|
||||||
|
membership {
|
||||||
|
role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Node struct {
|
||||||
|
Profiles struct {
|
||||||
|
Edges []struct {
|
||||||
|
Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Membership struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
} `json:"membership"`
|
||||||
|
} `json:"node"`
|
||||||
|
} `json:"edges"`
|
||||||
|
} `json:"profiles"`
|
||||||
|
} `json:"node"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := owner.ExecuteConnect(query, map[string]any{
|
||||||
|
"id": owner.GetOrganizationID().String(),
|
||||||
|
}, &result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var userID string
|
||||||
|
|
||||||
|
for _, edge := range result.Node.Profiles.Edges {
|
||||||
|
if edge.Node.Membership.Role == "VIEWER" {
|
||||||
|
userID = edge.Node.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotEmpty(t, userID, "Should find viewer member")
|
||||||
|
|
||||||
|
mutation := `
|
||||||
|
mutation($input: ArchiveUserInput!) {
|
||||||
|
archiveUser(input: $input) {
|
||||||
|
archivedProfileId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var mutationResult struct {
|
||||||
|
ArchiveUser struct {
|
||||||
|
ArchivedProfileID string `json:"archivedProfileId"`
|
||||||
|
} `json:"archiveUser"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = owner.ExecuteConnect(mutation, map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": owner.GetOrganizationID().String(),
|
||||||
|
"profileId": userID,
|
||||||
|
},
|
||||||
|
}, &mutationResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, userID, mutationResult.ArchiveUser.ArchivedProfileID)
|
||||||
|
|
||||||
|
err = owner.ExecuteConnect(query, map[string]any{
|
||||||
|
"id": owner.GetOrganizationID().String(),
|
||||||
|
}, &result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var archivedUserState string
|
||||||
|
|
||||||
|
for _, edge := range result.Node.Profiles.Edges {
|
||||||
|
if edge.Node.ID == userID {
|
||||||
|
archivedUserState = edge.Node.State
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotEmpty(t, archivedUserState, "Should still find archived user")
|
||||||
|
assert.Equal(t, "INACTIVE", archivedUserState)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUser_RemoveOwner(t *testing.T) {
|
func TestUser_RemoveOwner(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||||
|
import { proboConnectApiRequest } from '../../GenericFunctions';
|
||||||
|
|
||||||
|
export const description: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Organization ID',
|
||||||
|
name: 'organizationId',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['user'],
|
||||||
|
operation: ['archiveUser'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
description: 'The ID of the organization',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'User ID',
|
||||||
|
name: 'userId',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['user'],
|
||||||
|
operation: ['archiveUser'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
description: 'The ID of the user (profile) to archive in the organization',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function execute(
|
||||||
|
this: IExecuteFunctions,
|
||||||
|
itemIndex: number,
|
||||||
|
): Promise<INodeExecutionData> {
|
||||||
|
const organizationId = this.getNodeParameter('organizationId', itemIndex) as string;
|
||||||
|
const userId = this.getNodeParameter('userId', itemIndex) as string;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation ArchiveUser($input: ArchiveUserInput!) {
|
||||||
|
archiveUser(input: $input) {
|
||||||
|
archivedProfileId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = { organizationId, profileId: userId };
|
||||||
|
const responseData = await proboConnectApiRequest.call(this, query, { input });
|
||||||
|
|
||||||
|
return {
|
||||||
|
json: responseData,
|
||||||
|
pairedItem: { item: itemIndex },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import type { INodeProperties } from 'n8n-workflow';
|
import type { INodeProperties } from 'n8n-workflow';
|
||||||
|
import * as archiveUserOp from './archiveUser.operation';
|
||||||
import * as listUsersOp from './listUsers.operation';
|
import * as listUsersOp from './listUsers.operation';
|
||||||
import * as getUserOp from './getUser.operation';
|
import * as getUserOp from './getUser.operation';
|
||||||
import * as createUserOp from './createUser.operation';
|
import * as createUserOp from './createUser.operation';
|
||||||
@@ -35,7 +36,7 @@ export const description: INodeProperties[] = [
|
|||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
name: 'Archive',
|
name: 'Archive',
|
||||||
value: 'removeUser',
|
value: 'archiveUser',
|
||||||
description: 'Archive a user in the organization',
|
description: 'Archive a user in the organization',
|
||||||
action: 'Archive a user',
|
action: 'Archive a user',
|
||||||
},
|
},
|
||||||
@@ -63,6 +64,12 @@ export const description: INodeProperties[] = [
|
|||||||
description: 'List all users in the organization',
|
description: 'List all users in the organization',
|
||||||
action: 'List users',
|
action: 'List users',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Remove',
|
||||||
|
value: 'removeUser',
|
||||||
|
description: 'Remove a user from the organization',
|
||||||
|
action: 'Remove a user',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Update',
|
name: 'Update',
|
||||||
value: 'updateUser',
|
value: 'updateUser',
|
||||||
@@ -78,6 +85,7 @@ export const description: INodeProperties[] = [
|
|||||||
],
|
],
|
||||||
default: 'listUsers',
|
default: 'listUsers',
|
||||||
},
|
},
|
||||||
|
...archiveUserOp.description,
|
||||||
...listUsersOp.description,
|
...listUsersOp.description,
|
||||||
...getUserOp.description,
|
...getUserOp.description,
|
||||||
...createUserOp.description,
|
...createUserOp.description,
|
||||||
@@ -88,6 +96,7 @@ export const description: INodeProperties[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
archiveUserOp as archiveUser,
|
||||||
listUsersOp as listUsers,
|
listUsersOp as listUsers,
|
||||||
getUserOp as getUser,
|
getUserOp as getUser,
|
||||||
createUserOp as createUser,
|
createUserOp as createUser,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const description: INodeProperties[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
default: '',
|
default: '',
|
||||||
description: 'The ID of the user (profile) to archive in the organization',
|
description: 'The ID of the user (profile) to remove from the organization',
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -56,7 +56,7 @@ export async function execute(
|
|||||||
const query = `
|
const query = `
|
||||||
mutation RemoveUser($input: RemoveUserInput!) {
|
mutation RemoveUser($input: RemoveUserInput!) {
|
||||||
removeUser(input: $input) {
|
removeUser(input: $input) {
|
||||||
archivedProfileId: deletedProfileId
|
deletedProfileId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const archiveMutation = `
|
const archiveMutation = `
|
||||||
mutation($input: RemoveUserInput!) {
|
mutation($input: ArchiveUserInput!) {
|
||||||
removeUser(input: $input) {
|
archiveUser(input: $input) {
|
||||||
deletedProfileId
|
archivedProfileId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|||||||
114
pkg/cmd/user/remove/remove.go
Normal file
114
pkg/cmd/user/remove/remove.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package remove
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"go.probo.inc/probo/pkg/cli/api"
|
||||||
|
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeMutation = `
|
||||||
|
mutation($input: RemoveUserInput!) {
|
||||||
|
removeUser(input: $input) {
|
||||||
|
deletedProfileId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
func NewCmdRemove(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
var (
|
||||||
|
flagOrg string
|
||||||
|
flagYes bool
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "remove <id>",
|
||||||
|
Short: "Remove a user",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if !flagYes {
|
||||||
|
if !f.IOStreams.IsInteractive() {
|
||||||
|
return fmt.Errorf("cannot remove user: confirmation required, use --yes to confirm")
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirmed bool
|
||||||
|
|
||||||
|
err := huh.NewConfirm().
|
||||||
|
Title(fmt.Sprintf("Remove user %s?", args[0])).
|
||||||
|
Value(&confirmed).
|
||||||
|
Run()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !confirmed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := f.Config()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
host, hc, err := cfg.DefaultHost()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagOrg == "" {
|
||||||
|
flagOrg = hc.Organization
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagOrg == "" {
|
||||||
|
return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewClient(
|
||||||
|
host,
|
||||||
|
hc.Token,
|
||||||
|
"/api/console/v1/graphql",
|
||||||
|
cfg.HTTPTimeoutDuration(),
|
||||||
|
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err = client.Do(
|
||||||
|
removeMutation,
|
||||||
|
map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": flagOrg,
|
||||||
|
"profileId": args[0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintf(f.IOStreams.Out, "Removed user %s\n", args[0])
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
|
||||||
|
cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||||
"go.probo.inc/probo/pkg/cmd/user/archive"
|
"go.probo.inc/probo/pkg/cmd/user/archive"
|
||||||
"go.probo.inc/probo/pkg/cmd/user/list"
|
"go.probo.inc/probo/pkg/cmd/user/list"
|
||||||
|
"go.probo.inc/probo/pkg/cmd/user/remove"
|
||||||
"go.probo.inc/probo/pkg/cmd/user/view"
|
"go.probo.inc/probo/pkg/cmd/user/view"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ func NewCmdUser(f *cmdutil.Factory) *cobra.Command {
|
|||||||
cmd.AddCommand(list.NewCmdList(f))
|
cmd.AddCommand(list.NewCmdList(f))
|
||||||
cmd.AddCommand(view.NewCmdView(f))
|
cmd.AddCommand(view.NewCmdView(f))
|
||||||
cmd.AddCommand(archive.NewCmdArchive(f))
|
cmd.AddCommand(archive.NewCmdArchive(f))
|
||||||
|
cmd.AddCommand(remove.NewCmdRemove(f))
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,18 @@ func (e ErrUserManagedBySCIM) Error() string {
|
|||||||
return fmt.Sprintf("user %q is managed by SCIM and cannot be deleted manually", e.ProfileID)
|
return fmt.Sprintf("user %q is managed by SCIM and cannot be deleted manually", e.ProfileID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ErrUserReferencedByRecords struct {
|
||||||
|
ProfileID gid.GID
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserReferencedByRecordsError(profileID gid.GID) error {
|
||||||
|
return &ErrUserReferencedByRecords{ProfileID: profileID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrUserReferencedByRecords) Error() string {
|
||||||
|
return "cannot remove user because they are referenced by existing records (for example signatures, tasks, assets, or risks)"
|
||||||
|
}
|
||||||
|
|
||||||
type ErrLastActiveOwner struct {
|
type ErrLastActiveOwner struct {
|
||||||
MembershipID gid.GID
|
MembershipID gid.GID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"go.gearno.de/crypto/uuid"
|
"go.gearno.de/crypto/uuid"
|
||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
"go.probo.inc/probo/packages/emails"
|
"go.probo.inc/probo/packages/emails"
|
||||||
@@ -314,6 +315,73 @@ func (s *OrganizationService) RemoveUser(
|
|||||||
) error {
|
) error {
|
||||||
scope := coredata.NewScopeFromObjectID(organizationID)
|
scope := coredata.NewScopeFromObjectID(organizationID)
|
||||||
|
|
||||||
|
return s.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(ctx context.Context, tx pg.Tx) error {
|
||||||
|
profile := coredata.MembershipProfile{}
|
||||||
|
|
||||||
|
if err := profile.LoadByID(ctx, tx, scope, profileID); err != nil {
|
||||||
|
if err == coredata.ErrResourceNotFound {
|
||||||
|
return NewProfileNotFoundError(profileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("cannot load profile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.Source == coredata.ProfileSourceSCIM {
|
||||||
|
return NewUserManagedBySCIMError(profileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
membership := &coredata.Membership{}
|
||||||
|
if err := membership.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, profile.IdentityID, profile.OrganizationID); err != nil {
|
||||||
|
return fmt.Errorf("cannot load membership: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if membership.Role == coredata.MembershipRoleOwner && profile.State == coredata.ProfileStateActive {
|
||||||
|
profiles := coredata.MembershipProfiles{}
|
||||||
|
|
||||||
|
count, err := profiles.CountActiveOwnerByOrganizationID(ctx, tx, scope, organizationID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot count active owners: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count <= 1 {
|
||||||
|
return NewLastActiveOwnerError(profileID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := profile.Delete(ctx, tx, scope, profileID); err != nil {
|
||||||
|
if isUserRemovalDependencyError(err) {
|
||||||
|
return NewUserReferencedByRecordsError(profileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("cannot delete profile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := membership.Delete(ctx, tx, scope, membership.ID); err != nil {
|
||||||
|
if isUserRemovalDependencyError(err) {
|
||||||
|
return NewUserReferencedByRecordsError(profileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("cannot delete membership: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := webhook.InsertData(ctx, tx, scope, organizationID, coredata.WebhookEventTypeUserDeleted, webhooktypes.NewUser(&profile, membership)); err != nil {
|
||||||
|
return fmt.Errorf("cannot insert webhook event: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OrganizationService) ArchiveUser(
|
||||||
|
ctx context.Context,
|
||||||
|
organizationID gid.GID,
|
||||||
|
profileID gid.GID,
|
||||||
|
) error {
|
||||||
|
scope := coredata.NewScopeFromObjectID(organizationID)
|
||||||
|
|
||||||
return s.pg.WithTx(
|
return s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, tx pg.Tx) error {
|
func(ctx context.Context, tx pg.Tx) error {
|
||||||
@@ -374,6 +442,19 @@ func (s *OrganizationService) RemoveUser(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isUserRemovalDependencyError(err error) bool {
|
||||||
|
if errors.Is(err, coredata.ErrResourceInUse) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
pgErr, ok := errors.AsType[*pgconn.PgError](err)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return pgErr.Code == "23503"
|
||||||
|
}
|
||||||
|
|
||||||
func (s *OrganizationService) InviteUser(
|
func (s *OrganizationService) InviteUser(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *CreateInvitationRequest,
|
req *CreateInvitationRequest,
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ extend type Mutation {
|
|||||||
@session(required: PRESENT)
|
@session(required: PRESENT)
|
||||||
deactivateUser(input: DeactivateUserInput!): DeactivateUserPayload
|
deactivateUser(input: DeactivateUserInput!): DeactivateUserPayload
|
||||||
updateUser(input: UpdateUserInput!): UpdateUserPayload!
|
updateUser(input: UpdateUserInput!): UpdateUserPayload!
|
||||||
|
archiveUser(input: ArchiveUserInput!): ArchiveUserPayload
|
||||||
|
@session(required: PRESENT)
|
||||||
removeUser(input: RemoveUserInput!): RemoveUserPayload
|
removeUser(input: RemoveUserInput!): RemoveUserPayload
|
||||||
@session(required: PRESENT)
|
@session(required: PRESENT)
|
||||||
}
|
}
|
||||||
@@ -137,6 +139,11 @@ input RemoveUserInput {
|
|||||||
profileId: ID!
|
profileId: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input ArchiveUserInput {
|
||||||
|
organizationId: ID!
|
||||||
|
profileId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
type CreateUserPayload {
|
type CreateUserPayload {
|
||||||
profileEdge: ProfileEdge!
|
profileEdge: ProfileEdge!
|
||||||
}
|
}
|
||||||
@@ -152,3 +159,7 @@ type UpdateUserPayload {
|
|||||||
type RemoveUserPayload {
|
type RemoveUserPayload {
|
||||||
deletedProfileId: ID!
|
deletedProfileId: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ArchiveUserPayload {
|
||||||
|
archivedProfileId: ID!
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,13 +104,13 @@ func (r *mutationResolver) UpdateUser(ctx context.Context, input types.UpdateUse
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveUser is the resolver for the removeUser field.
|
// ArchiveUser is the resolver for the archiveUser field.
|
||||||
func (r *mutationResolver) RemoveUser(ctx context.Context, input types.RemoveUserInput) (*types.RemoveUserPayload, error) {
|
func (r *mutationResolver) ArchiveUser(ctx context.Context, input types.ArchiveUserInput) (*types.ArchiveUserPayload, error) {
|
||||||
if _, err := r.authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDelete); err != nil {
|
if _, err := r.authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDelete); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err := r.iam.OrganizationService.RemoveUser(ctx, input.OrganizationID, input.ProfileID)
|
err := r.iam.OrganizationService.ArchiveUser(ctx, input.OrganizationID, input.ProfileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if _, ok := errors.AsType[*iam.ErrUserManagedBySCIM](err); ok {
|
if _, ok := errors.AsType[*iam.ErrUserManagedBySCIM](err); ok {
|
||||||
return nil, gqlutils.Conflictf(ctx, "user is managed by SCIM and cannot be archived")
|
return nil, gqlutils.Conflictf(ctx, "user is managed by SCIM and cannot be archived")
|
||||||
@@ -120,8 +120,32 @@ func (r *mutationResolver) RemoveUser(ctx context.Context, input types.RemoveUse
|
|||||||
return nil, gqlutils.Conflictf(ctx, "cannot archive last active owner")
|
return nil, gqlutils.Conflictf(ctx, "cannot archive last active owner")
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, coredata.ErrResourceInUse) {
|
r.logger.ErrorCtx(ctx, "cannot archive user from organization", log.Error(err))
|
||||||
return nil, gqlutils.Conflictf(ctx, "cannot archive user")
|
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.ArchiveUserPayload{ArchivedProfileID: input.ProfileID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveUser is the resolver for the removeUser field.
|
||||||
|
func (r *mutationResolver) RemoveUser(ctx context.Context, input types.RemoveUserInput) (*types.RemoveUserPayload, error) {
|
||||||
|
if _, err := r.authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDelete); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.iam.OrganizationService.RemoveUser(ctx, input.OrganizationID, input.ProfileID)
|
||||||
|
if err != nil {
|
||||||
|
if _, ok := errors.AsType[*iam.ErrUserManagedBySCIM](err); ok {
|
||||||
|
return nil, gqlutils.Conflictf(ctx, "user is managed by SCIM and cannot be removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := errors.AsType[*iam.ErrLastActiveOwner](err); ok {
|
||||||
|
return nil, gqlutils.Conflictf(ctx, "cannot remove last active owner")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := errors.AsType[*iam.ErrUserReferencedByRecords](err); ok {
|
||||||
|
return nil, gqlutils.Conflictf(ctx, "cannot remove user because they are referenced by existing records (for example signatures, tasks, assets, or risks)")
|
||||||
}
|
}
|
||||||
|
|
||||||
r.logger.ErrorCtx(ctx, "cannot remove user from organization", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot remove user from organization", log.Error(err))
|
||||||
|
|||||||
@@ -2923,21 +2923,42 @@ func (r *Resolver) RemoveUserTool(ctx context.Context, req *mcp.CallToolRequest,
|
|||||||
err := r.iamSvc.OrganizationService.RemoveUser(ctx, input.OrganizationID, input.ProfileID)
|
err := r.iamSvc.OrganizationService.RemoveUser(ctx, input.OrganizationID, input.ProfileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if _, ok := errors.AsType[*iam.ErrUserManagedBySCIM](err); ok {
|
if _, ok := errors.AsType[*iam.ErrUserManagedBySCIM](err); ok {
|
||||||
return nil, types.RemoveUserOutput{}, fmt.Errorf("user is managed by SCIM and cannot be archived: %w", err)
|
return nil, types.RemoveUserOutput{}, fmt.Errorf("user is managed by SCIM and cannot be removed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := errors.AsType[*iam.ErrLastActiveOwner](err); ok {
|
if _, ok := errors.AsType[*iam.ErrLastActiveOwner](err); ok {
|
||||||
return nil, types.RemoveUserOutput{}, fmt.Errorf("cannot archive last active owner: %w", err)
|
return nil, types.RemoveUserOutput{}, fmt.Errorf("cannot remove last active owner: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, coredata.ErrResourceInUse) {
|
if _, ok := errors.AsType[*iam.ErrUserReferencedByRecords](err); ok {
|
||||||
return nil, types.RemoveUserOutput{}, fmt.Errorf("cannot archive user: %w", err)
|
return nil, types.RemoveUserOutput{}, fmt.Errorf("cannot remove user because they are referenced by existing records: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, types.RemoveUserOutput{}, fmt.Errorf("archive user: %w", err)
|
return nil, types.RemoveUserOutput{}, fmt.Errorf("remove user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, types.RemoveUserOutput{ArchivedUserID: input.ProfileID}, nil
|
return nil, types.RemoveUserOutput{DeletedUserID: input.ProfileID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resolver) ArchiveUserTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ArchiveUserInput) (*mcp.CallToolResult, types.ArchiveUserOutput, error) {
|
||||||
|
if _, err := r.Authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDelete); err != nil {
|
||||||
|
return nil, types.ArchiveUserOutput{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.iamSvc.OrganizationService.ArchiveUser(ctx, input.OrganizationID, input.ProfileID)
|
||||||
|
if err != nil {
|
||||||
|
if _, ok := errors.AsType[*iam.ErrUserManagedBySCIM](err); ok {
|
||||||
|
return nil, types.ArchiveUserOutput{}, fmt.Errorf("user is managed by SCIM and cannot be archived: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := errors.AsType[*iam.ErrLastActiveOwner](err); ok {
|
||||||
|
return nil, types.ArchiveUserOutput{}, fmt.Errorf("cannot archive last active owner: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, types.ArchiveUserOutput{}, fmt.Errorf("archive user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, types.ArchiveUserOutput{ArchivedUserID: input.ProfileID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Resolver) DeleteDataProtectionImpactAssessmentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteDataProtectionImpactAssessmentInput) (*mcp.CallToolResult, types.DeleteDataProtectionImpactAssessmentOutput, error) {
|
func (r *Resolver) DeleteDataProtectionImpactAssessmentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteDataProtectionImpactAssessmentInput) (*mcp.CallToolResult, types.DeleteDataProtectionImpactAssessmentOutput, error) {
|
||||||
|
|||||||
@@ -1709,9 +1709,31 @@ components:
|
|||||||
description: Organization ID
|
description: Organization ID
|
||||||
profile_id:
|
profile_id:
|
||||||
$ref: "#/components/schemas/GID"
|
$ref: "#/components/schemas/GID"
|
||||||
description: User (profile) ID to archive
|
description: User (profile) ID to remove
|
||||||
|
|
||||||
RemoveUserOutput:
|
RemoveUserOutput:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- deleted_user_id
|
||||||
|
properties:
|
||||||
|
deleted_user_id:
|
||||||
|
$ref: "#/components/schemas/GID"
|
||||||
|
description: Deleted user (profile) ID
|
||||||
|
|
||||||
|
ArchiveUserInput:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- organization_id
|
||||||
|
- profile_id
|
||||||
|
properties:
|
||||||
|
organization_id:
|
||||||
|
$ref: "#/components/schemas/GID"
|
||||||
|
description: Organization ID
|
||||||
|
profile_id:
|
||||||
|
$ref: "#/components/schemas/GID"
|
||||||
|
description: User (profile) ID to archive
|
||||||
|
|
||||||
|
ArchiveUserOutput:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- archived_user_id
|
- archived_user_id
|
||||||
@@ -11934,13 +11956,22 @@ tools:
|
|||||||
outputSchema:
|
outputSchema:
|
||||||
$ref: "#/components/schemas/UpdateMembershipOutput"
|
$ref: "#/components/schemas/UpdateMembershipOutput"
|
||||||
- name: removeUser
|
- name: removeUser
|
||||||
description: Archive a user in the organization
|
description: Remove a user from the organization
|
||||||
hints:
|
hints:
|
||||||
readonly: false
|
readonly: false
|
||||||
|
destructive: true
|
||||||
inputSchema:
|
inputSchema:
|
||||||
$ref: "#/components/schemas/RemoveUserInput"
|
$ref: "#/components/schemas/RemoveUserInput"
|
||||||
outputSchema:
|
outputSchema:
|
||||||
$ref: "#/components/schemas/RemoveUserOutput"
|
$ref: "#/components/schemas/RemoveUserOutput"
|
||||||
|
- name: archiveUser
|
||||||
|
description: Archive a user in the organization
|
||||||
|
hints:
|
||||||
|
readonly: false
|
||||||
|
inputSchema:
|
||||||
|
$ref: "#/components/schemas/ArchiveUserInput"
|
||||||
|
outputSchema:
|
||||||
|
$ref: "#/components/schemas/ArchiveUserOutput"
|
||||||
- name: addThirdParty
|
- name: addThirdParty
|
||||||
description: Add a new thirdParty to the organization
|
description: Add a new thirdParty to the organization
|
||||||
hints:
|
hints:
|
||||||
|
|||||||
Reference in New Issue
Block a user