Rename user archive action to deactivate

"Archive" was misleading for users: the action sets a profile to
DEACTIVATED while keeping the person in the organization. Rename it to
"deactivate" across the API, CLI, MCP, n8n, and console UI.

Consolidate the two overlapping operations into a single deactivateUser
backed by the fuller, guarded logic (SCIM guard, last-active-owner
guard, invitation expiry, signature cancellation, membership update,
webhook) and authorized via iam:membership-profile:deactivate. Remove
the archiveUser surface and the thin state-only deactivate path.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-28 17:30:14 +02:00
parent 99c3235b46
commit b10fc55b7f
14 changed files with 130 additions and 208 deletions

View File

@@ -1361,10 +1361,10 @@
"peoplePage": { "title": "People", "actions": { "add": "Add Person" } }, "peoplePage": { "title": "People", "actions": { "add": "Add Person" } },
"personPage": { "personPage": {
"breadcrumb": { "people": "People" }, "breadcrumb": { "people": "People" },
"messages": { "archived": "Person archived successfully", "removed": "Person removed successfully" }, "messages": { "deactivated": "Person deactivated successfully", "removed": "Person removed successfully" },
"errors": { "archive": "Failed to archive person", "remove": "Failed to remove person" }, "errors": { "deactivate": "Failed to deactivate person", "remove": "Failed to remove person" },
"confirmations": { "archive": "Are you sure you want to archive {{name}}?", "remove": "Are you sure you want to remove {{name}}?" }, "confirmations": { "deactivate": "Are you sure you want to deactivate {{name}}?", "remove": "Are you sure you want to remove {{name}}?" },
"actions": { "archive": "Archive", "remove": "Remove" } "actions": { "deactivate": "Deactivate", "remove": "Remove" }
}, },
"addPersonDialog": { "title": "Add Person" }, "addPersonDialog": { "title": "Add Person" },
"peopleList": { "peopleList": {
@@ -1374,10 +1374,10 @@
"filters": { "allStatuses": "All statuses", "pending": "Pending", "active": "Active", "deactivated": "Deactivated", "allRoles": "All roles", "allTypes": "All types" } "filters": { "allStatuses": "All statuses", "pending": "Pending", "active": "Active", "deactivated": "Deactivated", "allRoles": "All roles", "allTypes": "All types" }
}, },
"peopleListItem": { "peopleListItem": {
"messages": { "invitationSent": "Invitation sent successfully", "roleUpdated": "Role updated successfully", "archived": "Person archived successfully", "removed": "Person removed successfully" }, "messages": { "invitationSent": "Invitation sent successfully", "roleUpdated": "Role updated successfully", "deactivated": "Person deactivated 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" }, "errors": { "sendInvitation": "Failed to send invitation", "updateRole": "Failed to update role", "deactivate": "Failed to deactivate 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}}?" }, "confirmations": { "sendActivationEmail": "Send the activation email to {{name}}?", "deactivate": "Are you sure you want to deactivate {{name}}?", "remove": "Are you sure you want to remove {{name}}?" },
"actions": { "send": "Send", "sendActivationMail": "Send activation mail", "resendActivationMail": "Resend activation mail", "archivePerson": "Archive person", "removePerson": "Remove person" } "actions": { "send": "Send", "sendActivationMail": "Send activation mail", "resendActivationMail": "Resend activation mail", "deactivatePerson": "Deactivate person", "removePerson": "Remove person" }
}, },
"personForm": { "personForm": {
"messages": { "created": "Person created successfully.", "updated": "Person updated successfully." }, "messages": { "created": "Person created successfully.", "updated": "Person updated successfully." },

View File

@@ -2427,19 +2427,19 @@
"people": "Personnes" "people": "Personnes"
}, },
"messages": { "messages": {
"archived": "Personne archivée avec succès", "deactivated": "Personne désactivée avec succès",
"removed": "Personne supprimée avec succès" "removed": "Personne supprimée avec succès"
}, },
"errors": { "errors": {
"archive": "Échec de l’archivage de la personne", "deactivate": "Échec de la désactivation de la personne",
"remove": "Échec de la suppression de la personne" "remove": "Échec de la suppression de la personne"
}, },
"confirmations": { "confirmations": {
"archive": "Voulez-vous vraiment archiver {{name}} ?", "deactivate": "Voulez-vous vraiment désactiver {{name}} ?",
"remove": "Voulez-vous vraiment supprimer {{name}} ?" "remove": "Voulez-vous vraiment supprimer {{name}} ?"
}, },
"actions": { "actions": {
"archive": "Archiver", "deactivate": "Désactiver",
"remove": "Supprimer" "remove": "Supprimer"
} }
}, },
@@ -2469,25 +2469,25 @@
"messages": { "messages": {
"invitationSent": "Invitation envoyée avec succès", "invitationSent": "Invitation envoyée avec succès",
"roleUpdated": "Rôle mis à jour avec succès", "roleUpdated": "Rôle mis à jour avec succès",
"archived": "Personne archivée avec succès", "deactivated": "Personne désactivée avec succès",
"removed": "Personne supprimée avec succès" "removed": "Personne supprimée avec succès"
}, },
"errors": { "errors": {
"sendInvitation": "Échec de l’envoi de l’invitation", "sendInvitation": "Échec de l’envoi de l’invitation",
"updateRole": "Échec de la mise à jour du rôle", "updateRole": "Échec de la mise à jour du rôle",
"archive": "Échec de l’archivage de la personne", "deactivate": "Échec de la désactivation de la personne",
"remove": "Échec de la suppression de la personne" "remove": "Échec de la suppression de la personne"
}, },
"confirmations": { "confirmations": {
"sendActivationEmail": "Envoyer l’e-mail d’activation à {{name}} ?", "sendActivationEmail": "Envoyer l’e-mail d’activation à {{name}} ?",
"archive": "Voulez-vous vraiment archiver {{name}} ?", "deactivate": "Voulez-vous vraiment désactiver {{name}} ?",
"remove": "Voulez-vous vraiment supprimer {{name}} ?" "remove": "Voulez-vous vraiment supprimer {{name}} ?"
}, },
"actions": { "actions": {
"send": "Envoyer", "send": "Envoyer",
"sendActivationMail": "Envoyer l’e-mail d’activation", "sendActivationMail": "Envoyer l’e-mail d’activation",
"resendActivationMail": "Renvoyer l’e-mail d’activation", "resendActivationMail": "Renvoyer l’e-mail d’activation",
"archivePerson": "Archiver la personne", "deactivatePerson": "Désactiver la personne",
"removePerson": "Supprimer la personne" "removePerson": "Supprimer la personne"
} }
}, },

View File

@@ -40,7 +40,7 @@ export const personPageQuery = graphql`
emailAddress emailAddress
source source
state state
canDelete: permission(action: "iam:membership-profile:delete") canDeactivate: permission(action: "iam:membership-profile:deactivate")
canRemoveMember: permission(action: "iam:membership:delete") canRemoveMember: permission(action: "iam:membership:delete")
...PersonFormFragment ...PersonFormFragment
} }
@@ -58,12 +58,12 @@ const removeUserMutation = graphql`
} }
`; `;
const archiveUserMutation = graphql` const deactivateUserMutation = graphql`
mutation PersonPage_archiveMutation( mutation PersonPage_deactivateMutation(
$input: ArchiveUserInput! $input: DeactivateUserInput!
) { ) {
archiveUser(input: $input) { deactivateUser(input: $input) {
archivedProfileId success
} }
} }
`; `;
@@ -81,11 +81,11 @@ export function PersonPage(props: { queryRef: PreloadedQuery<PersonPageQuery> })
throw new Error("invalid type for node"); throw new Error("invalid type for node");
} }
const [archiveUser, isArchiving] = useMutationWithToasts( const [deactivateUser, isDeactivating] = useMutationWithToasts(
archiveUserMutation, deactivateUserMutation,
{ {
successMessage: t("personPage.messages.archived"), successMessage: t("personPage.messages.deactivated"),
errorMessage: t("personPage.errors.archive"), errorMessage: t("personPage.errors.deactivate"),
}, },
); );
const [removeUser, isRemoving] = useMutationWithToasts( const [removeUser, isRemoving] = useMutationWithToasts(
@@ -95,12 +95,12 @@ export function PersonPage(props: { queryRef: PreloadedQuery<PersonPageQuery> })
errorMessage: t("personPage.errors.remove"), errorMessage: t("personPage.errors.remove"),
}, },
); );
const isMutating = isArchiving || isRemoving; const isMutating = isDeactivating || isRemoving;
const handleArchive = () => { const handleDeactivate = () => {
confirm( confirm(
() => { () => {
return archiveUser({ return deactivateUser({
variables: { variables: {
input: { input: {
profileId: person.id, profileId: person.id,
@@ -113,7 +113,7 @@ export function PersonPage(props: { queryRef: PreloadedQuery<PersonPageQuery> })
}); });
}, },
{ {
message: t("personPage.confirmations.archive", { name: person.fullName }), message: t("personPage.confirmations.deactivate", { name: person.fullName }),
}, },
); );
}; };
@@ -139,7 +139,7 @@ export function PersonPage(props: { queryRef: PreloadedQuery<PersonPageQuery> })
); );
}; };
const canArchive = person.canDelete && person.source !== "SCIM" && person.state !== "DEACTIVATED"; const canDeactivate = person.canDeactivate && person.source !== "SCIM" && person.state !== "DEACTIVATED";
const canRemove = person.canRemoveMember && person.source !== "SCIM"; const canRemove = person.canRemoveMember && person.source !== "SCIM";
return ( return (
@@ -166,15 +166,15 @@ 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 || canRemove) && ( {(canDeactivate || canRemove) && (
<ActionDropdown variant="secondary"> <ActionDropdown variant="secondary">
{canArchive && ( {canDeactivate && (
<DropdownItem <DropdownItem
icon={IconArchive} icon={IconArchive}
onClick={handleArchive} onClick={handleDeactivate}
disabled={isMutating} disabled={isMutating}
> >
{t("personPage.actions.archive")} {t("personPage.actions.deactivate")}
</DropdownItem> </DropdownItem>
)} )}
{canRemove && ( {canRemove && (

View File

@@ -71,7 +71,7 @@ const fragment = graphql`
createdAt createdAt
canUpdate: permission(action: "iam:membership-profile:update") canUpdate: permission(action: "iam:membership-profile:update")
canInvite: permission(action: "iam:invitation:create") canInvite: permission(action: "iam:invitation:create")
canDelete: permission(action: "iam:membership-profile:delete") canDeactivate: permission(action: "iam:membership-profile:deactivate")
canRemoveMember: permission(action: "iam:membership:delete") canRemoveMember: permission(action: "iam:membership:delete")
} }
`; `;
@@ -116,10 +116,10 @@ const removeUserMutation = graphql`
} }
`; `;
const archiveUserMutation = graphql` const deactivateUserMutation = graphql`
mutation PeopleListItem_archiveMutation($input: ArchiveUserInput!) { mutation PeopleListItem_deactivateMutation($input: DeactivateUserInput!) {
archiveUser(input: $input) { deactivateUser(input: $input) {
archivedProfileId success
} }
} }
`; `;
@@ -149,7 +149,7 @@ export function PeopleListItem(props: {
const isInactive = !isActive; const isInactive = !isActive;
const canSendActivationMail = !isActive && profile.source !== "SCIM" && profile.canInvite; const canSendActivationMail = !isActive && profile.source !== "SCIM" && profile.canInvite;
const canArchive = profile.canDelete && profile.source !== "SCIM" && profile.state !== "DEACTIVATED"; const canDeactivate = profile.canDeactivate && profile.source !== "SCIM" && profile.state !== "DEACTIVATED";
const canRemove = profile.canRemoveMember && profile.source !== "SCIM"; const canRemove = profile.canRemoveMember && profile.source !== "SCIM";
const [inviteUser] const [inviteUser]
@@ -164,11 +164,11 @@ export function PeopleListItem(props: {
errorMessage: t("peopleListItem.errors.updateRole"), errorMessage: t("peopleListItem.errors.updateRole"),
}, },
); );
const [archiveUser, isArchiving] = useMutationWithToasts( const [deactivateUser, isDeactivating] = useMutationWithToasts(
archiveUserMutation, deactivateUserMutation,
{ {
successMessage: t("peopleListItem.messages.archived"), successMessage: t("peopleListItem.messages.deactivated"),
errorMessage: t("peopleListItem.errors.archive"), errorMessage: t("peopleListItem.errors.deactivate"),
}, },
); );
const [removeUser, isRemoving] = useMutationWithToasts( const [removeUser, isRemoving] = useMutationWithToasts(
@@ -178,7 +178,7 @@ export function PeopleListItem(props: {
errorMessage: t("peopleListItem.errors.remove"), errorMessage: t("peopleListItem.errors.remove"),
}, },
); );
const isMutating = isArchiving || isRemoving; const isMutating = isDeactivating || isRemoving;
const handleInvite = () => { const handleInvite = () => {
confirm( confirm(
@@ -211,10 +211,10 @@ export function PeopleListItem(props: {
}, },
}); });
}; };
const handleArchive = () => { const handleDeactivate = () => {
confirm( confirm(
() => { () => {
return archiveUser({ return deactivateUser({
variables: { variables: {
input: { input: {
profileId: profile.id, profileId: profile.id,
@@ -227,7 +227,7 @@ export function PeopleListItem(props: {
}); });
}, },
{ {
message: t("peopleListItem.confirmations.archive", { name: profile.fullName }), message: t("peopleListItem.confirmations.deactivate", { name: profile.fullName }),
}, },
); );
}; };
@@ -307,7 +307,7 @@ export function PeopleListItem(props: {
{dateFormat(i18n.language, profile.createdAt)} {dateFormat(i18n.language, profile.createdAt)}
</Td> </Td>
<Td noLink width={160} className="text-end"> <Td noLink width={160} className="text-end">
{(canSendActivationMail || canArchive || canRemove) && ( {(canSendActivationMail || canDeactivate || canRemove) && (
<ActionDropdown> <ActionDropdown>
{canSendActivationMail && ( {canSendActivationMail && (
<DropdownItem <DropdownItem
@@ -317,12 +317,12 @@ export function PeopleListItem(props: {
{lastInvitation ? t("peopleListItem.actions.resendActivationMail") : t("peopleListItem.actions.sendActivationMail")} {lastInvitation ? t("peopleListItem.actions.resendActivationMail") : t("peopleListItem.actions.sendActivationMail")}
</DropdownItem> </DropdownItem>
)} )}
{canArchive && ( {canDeactivate && (
<DropdownItem <DropdownItem
onClick={handleArchive} onClick={handleDeactivate}
icon={IconArchive} icon={IconArchive}
> >
{t("peopleListItem.actions.archivePerson")} {t("peopleListItem.actions.deactivatePerson")}
</DropdownItem> </DropdownItem>
)} )}
{canRemove && ( {canRemove && (

View File

@@ -605,13 +605,13 @@ func TestUser_RemoveUser_ProfileInUse(t *testing.T) {
assert.Contains(t, gqlErrors[0].Message, "referenced by other resources") assert.Contains(t, gqlErrors[0].Message, "referenced by other resources")
} }
func TestUser_ArchiveUser(t *testing.T) { func TestUser_DeactivateUser(t *testing.T) {
t.Parallel() t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner) owner := testutil.NewClient(t, testutil.RoleOwner)
// Create a user to archive. // Create a user to deactivate.
userToArchive := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) userToDeactivate := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
_ = userToArchive _ = userToDeactivate
query := ` query := `
query($id: ID!) { query($id: ID!) {
@@ -666,17 +666,17 @@ func TestUser_ArchiveUser(t *testing.T) {
require.NotEmpty(t, userID, "Should find viewer member") require.NotEmpty(t, userID, "Should find viewer member")
mutation := ` mutation := `
mutation($input: ArchiveUserInput!) { mutation($input: DeactivateUserInput!) {
archiveUser(input: $input) { deactivateUser(input: $input) {
archivedProfileId success
} }
} }
` `
var mutationResult struct { var mutationResult struct {
ArchiveUser struct { DeactivateUser struct {
ArchivedProfileID string `json:"archivedProfileId"` Success bool `json:"success"`
} `json:"archiveUser"` } `json:"deactivateUser"`
} }
err = owner.ExecuteConnect(mutation, map[string]any{ err = owner.ExecuteConnect(mutation, map[string]any{
@@ -687,24 +687,24 @@ func TestUser_ArchiveUser(t *testing.T) {
}, &mutationResult) }, &mutationResult)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, userID, mutationResult.ArchiveUser.ArchivedProfileID) assert.True(t, mutationResult.DeactivateUser.Success)
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 archivedUserState string var deactivatedUserState string
for _, edge := range result.Node.Profiles.Edges { for _, edge := range result.Node.Profiles.Edges {
if edge.Node.ID == userID { if edge.Node.ID == userID {
archivedUserState = edge.Node.State deactivatedUserState = edge.Node.State
break break
} }
} }
require.NotEmpty(t, archivedUserState, "Should still find archived user") require.NotEmpty(t, deactivatedUserState, "Should still find deactivated user")
assert.Equal(t, "DEACTIVATED", archivedUserState) assert.Equal(t, "DEACTIVATED", deactivatedUserState)
} }
func TestUser_DeactivateUserCancelsSignatureRequests(t *testing.T) { func TestUser_DeactivateUserCancelsSignatureRequests(t *testing.T) {

View File

@@ -29,7 +29,7 @@ export const description: INodeProperties[] = [
displayOptions: { displayOptions: {
show: { show: {
resource: ['user'], resource: ['user'],
operation: ['archiveUser'], operation: ['deactivateUser'],
}, },
}, },
default: '', default: '',
@@ -43,11 +43,11 @@ export const description: INodeProperties[] = [
displayOptions: { displayOptions: {
show: { show: {
resource: ['user'], resource: ['user'],
operation: ['archiveUser'], operation: ['deactivateUser'],
}, },
}, },
default: '', default: '',
description: 'The ID of the user (profile) to archive in the organization', description: 'The ID of the user (profile) to deactivate in the organization',
required: true, required: true,
}, },
]; ];
@@ -60,9 +60,9 @@ export async function execute(
const userId = this.getNodeParameter('userId', itemIndex) as string; const userId = this.getNodeParameter('userId', itemIndex) as string;
const query = ` const query = `
mutation ArchiveUser($input: ArchiveUserInput!) { mutation DeactivateUser($input: DeactivateUserInput!) {
archiveUser(input: $input) { deactivateUser(input: $input) {
archivedProfileId success
} }
} }
`; `;

View File

@@ -19,7 +19,7 @@
// SOFTWARE. // SOFTWARE.
import type { INodeProperties } from 'n8n-workflow'; import type { INodeProperties } from 'n8n-workflow';
import * as archiveUserOp from './archiveUser.operation'; import * as deactivateUserOp from './deactivateUser.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';
@@ -40,18 +40,18 @@ export const description: INodeProperties[] = [
}, },
}, },
options: [ options: [
{
name: 'Archive',
value: 'archiveUser',
description: 'Archive a user in the organization',
action: 'Archive a user',
},
{ {
name: 'Create', name: 'Create',
value: 'createUser', value: 'createUser',
description: 'Create a new user in the organization', description: 'Create a new user in the organization',
action: 'Create a user', action: 'Create a user',
}, },
{
name: 'Deactivate',
value: 'deactivateUser',
description: 'Deactivate a user in the organization',
action: 'Deactivate a user',
},
{ {
name: 'Get', name: 'Get',
value: 'getUser', value: 'getUser',
@@ -91,7 +91,7 @@ export const description: INodeProperties[] = [
], ],
default: 'listUsers', default: 'listUsers',
}, },
...archiveUserOp.description, ...deactivateUserOp.description,
...listUsersOp.description, ...listUsersOp.description,
...getUserOp.description, ...getUserOp.description,
...createUserOp.description, ...createUserOp.description,
@@ -102,7 +102,7 @@ export const description: INodeProperties[] = [
]; ];
export { export {
archiveUserOp as archiveUser, deactivateUserOp as deactivateUser,
listUsersOp as listUsers, listUsersOp as listUsers,
getUserOp as getUser, getUserOp as getUser,
createUserOp as createUser, createUserOp as createUser,

View File

@@ -18,7 +18,7 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE. // SOFTWARE.
package archive package deactivate
import ( import (
"fmt" "fmt"
@@ -29,34 +29,34 @@ import (
"go.probo.inc/probo/pkg/cmd/cmdutil" "go.probo.inc/probo/pkg/cmd/cmdutil"
) )
const archiveMutation = ` const deactivateMutation = `
mutation($input: ArchiveUserInput!) { mutation($input: DeactivateUserInput!) {
archiveUser(input: $input) { deactivateUser(input: $input) {
archivedProfileId success
} }
} }
` `
func NewCmdArchive(f *cmdutil.Factory) *cobra.Command { func NewCmdDeactivate(f *cmdutil.Factory) *cobra.Command {
var ( var (
flagOrg string flagOrg string
flagYes bool flagYes bool
) )
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "archive <id>", Use: "deactivate <id>",
Short: "Archive a user", Short: "Deactivate a user",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if !flagYes { if !flagYes {
if !f.IOStreams.IsInteractive() { if !f.IOStreams.IsInteractive() {
return fmt.Errorf("cannot archive user: confirmation required, use --yes to confirm") return fmt.Errorf("cannot deactivate user: confirmation required, use --yes to confirm")
} }
var confirmed bool var confirmed bool
err := huh.NewConfirm(). err := huh.NewConfirm().
Title(fmt.Sprintf("Archive user %s?", args[0])). Title(fmt.Sprintf("Deactivate user %s?", args[0])).
Value(&confirmed). Value(&confirmed).
Run() Run()
if err != nil { if err != nil {
@@ -95,7 +95,7 @@ func NewCmdArchive(f *cmdutil.Factory) *cobra.Command {
) )
_, err = client.Do( _, err = client.Do(
archiveMutation, deactivateMutation,
map[string]any{ map[string]any{
"input": map[string]any{ "input": map[string]any{
"organizationId": flagOrg, "organizationId": flagOrg,
@@ -107,7 +107,7 @@ func NewCmdArchive(f *cmdutil.Factory) *cobra.Command {
return err return err
} }
_, _ = fmt.Fprintf(f.IOStreams.Out, "Archived user %s\n", args[0]) _, _ = fmt.Fprintf(f.IOStreams.Out, "Deactivated user %s\n", args[0])
return nil return nil
}, },

View File

@@ -23,7 +23,7 @@ package user
import ( import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"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/deactivate"
"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/remove"
"go.probo.inc/probo/pkg/cmd/user/view" "go.probo.inc/probo/pkg/cmd/user/view"
@@ -37,7 +37,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(deactivate.NewCmdDeactivate(f))
cmd.AddCommand(remove.NewCmdRemove(f)) cmd.AddCommand(remove.NewCmdRemove(f))
return cmd return cmd

View File

@@ -374,7 +374,7 @@ func (s *OrganizationService) RemoveUser(
) )
} }
func (s *OrganizationService) ArchiveUser( func (s *OrganizationService) DeactivateUser(
ctx context.Context, ctx context.Context,
scope coredata.Scoper, scope coredata.Scoper,
organizationID gid.GID, organizationID gid.GID,
@@ -1183,55 +1183,6 @@ func (s *OrganizationService) UpdateUser(ctx context.Context, req *UpdateUserReq
return profile, nil return profile, nil
} }
func (s *OrganizationService) UpdateUserState(
ctx context.Context,
userID gid.GID,
state coredata.ProfileState,
) (*coredata.MembershipProfile, error) {
var (
scope = coredata.NewScopeFromObjectID(userID)
profile = &coredata.MembershipProfile{}
)
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := profile.LoadByID(ctx, tx, scope, userID); err != nil {
return fmt.Errorf("cannot load profile: %w", err)
}
now := time.Now()
switch state {
case coredata.ProfileStatePending:
profile.MarkPending(now)
case coredata.ProfileStateActive:
profile.MarkActive(now)
case coredata.ProfileStateDeactivated:
profile.MarkDeactivated(now)
}
if err := profile.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update profile: %w", err)
}
if state == coredata.ProfileStateDeactivated {
signatures := &coredata.DocumentVersionSignatures{}
if err := signatures.DeleteRequestedBySignatory(ctx, tx, scope, profile.ID); err != nil {
return fmt.Errorf("cannot delete requested signatures: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, err
}
return profile, nil
}
func (s *OrganizationService) GetProfile(ctx context.Context, profileID gid.GID) (*coredata.MembershipProfile, error) { func (s *OrganizationService) GetProfile(ctx context.Context, profileID gid.GID) (*coredata.MembershipProfile, error) {
profile := &coredata.MembershipProfile{} profile := &coredata.MembershipProfile{}

View File

@@ -104,9 +104,8 @@ extend type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload createUser(input: CreateUserInput!): CreateUserPayload
@authentication(required: PRESENT) @authentication(required: PRESENT)
deactivateUser(input: DeactivateUserInput!): DeactivateUserPayload deactivateUser(input: DeactivateUserInput!): DeactivateUserPayload
updateUser(input: UpdateUserInput!): UpdateUserPayload!
archiveUser(input: ArchiveUserInput!): ArchiveUserPayload
@authentication(required: PRESENT) @authentication(required: PRESENT)
updateUser(input: UpdateUserInput!): UpdateUserPayload!
removeUser(input: RemoveUserInput!): RemoveUserPayload removeUser(input: RemoveUserInput!): RemoveUserPayload
@authentication(required: PRESENT) @authentication(required: PRESENT)
} }
@@ -148,11 +147,6 @@ input RemoveUserInput {
profileId: ID! profileId: ID!
} }
input ArchiveUserInput {
organizationId: ID!
profileId: ID!
}
type CreateUserPayload { type CreateUserPayload {
profileEdge: ProfileEdge! profileEdge: ProfileEdge!
} }
@@ -168,7 +162,3 @@ type UpdateUserPayload {
type RemoveUserPayload { type RemoveUserPayload {
deletedProfileId: ID! deletedProfileId: ID!
} }
type ArchiveUserPayload {
archivedProfileId: ID!
}

View File

@@ -64,17 +64,23 @@ func (r *mutationResolver) CreateUser(ctx context.Context, input types.CreateUse
// DeactivateUser is the resolver for the deactivateUser field. // DeactivateUser is the resolver for the deactivateUser field.
func (r *mutationResolver) DeactivateUser(ctx context.Context, input types.DeactivateUserInput) (*types.DeactivateUserPayload, error) { func (r *mutationResolver) DeactivateUser(ctx context.Context, input types.DeactivateUserInput) (*types.DeactivateUserPayload, error) {
if _, err := r.authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDeactivate); err != nil { scope, err := r.authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDeactivate)
if err != nil {
return nil, err return nil, err
} }
_, err := r.iam.OrganizationService.UpdateUserState( err = r.iam.OrganizationService.DeactivateUser(ctx, scope, input.OrganizationID, input.ProfileID)
ctx,
input.ProfileID,
coredata.ProfileStateDeactivated,
)
if err != nil { if err != nil {
r.logger.ErrorCtx(ctx, "cannot deactivate profile", log.Error(err)) if _, ok := errors.AsType[*iam.ErrUserManagedBySCIM](err); ok {
return nil, gqlutils.Conflictf(ctx, "user is managed by SCIM and cannot be deactivated")
}
if _, ok := errors.AsType[*iam.ErrLastActiveOwner](err); ok {
return nil, gqlutils.Conflictf(ctx, "cannot deactivate last active owner")
}
r.logger.ErrorCtx(ctx, "cannot deactivate user", log.Error(err))
return nil, gqlutils.Internal(ctx) return nil, gqlutils.Internal(ctx)
} }
@@ -111,31 +117,6 @@ func (r *mutationResolver) UpdateUser(ctx context.Context, input types.UpdateUse
}, nil }, nil
} }
// ArchiveUser is the resolver for the archiveUser field.
func (r *mutationResolver) ArchiveUser(ctx context.Context, input types.ArchiveUserInput) (*types.ArchiveUserPayload, error) {
scope, err := r.authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDelete)
if err != nil {
return nil, err
}
err = r.iam.OrganizationService.ArchiveUser(ctx, scope, 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 archived")
}
if _, ok := errors.AsType[*iam.ErrLastActiveOwner](err); ok {
return nil, gqlutils.Conflictf(ctx, "cannot archive last active owner")
}
r.logger.ErrorCtx(ctx, "cannot archive user from organization", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.ArchiveUserPayload{ArchivedProfileID: input.ProfileID}, nil
}
// RemoveUser is the resolver for the removeUser field. // RemoveUser is the resolver for the removeUser field.
func (r *mutationResolver) RemoveUser(ctx context.Context, input types.RemoveUserInput) (*types.RemoveUserPayload, error) { func (r *mutationResolver) RemoveUser(ctx context.Context, input types.RemoveUserInput) (*types.RemoveUserPayload, error) {
scope, err := r.authorize(ctx, input.ProfileID, iam.ActionMembershipDelete) scope, err := r.authorize(ctx, input.ProfileID, iam.ActionMembershipDelete)

View File

@@ -2994,26 +2994,26 @@ func (r *Resolver) RemoveUserTool(ctx context.Context, req *mcp.CallToolRequest,
return nil, types.RemoveUserOutput{DeletedUserID: 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) { func (r *Resolver) DeactivateUserTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeactivateUserInput) (*mcp.CallToolResult, types.DeactivateUserOutput, error) {
scope, err := r.Authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDelete) scope, err := r.Authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDeactivate)
if err != nil { if err != nil {
return nil, types.ArchiveUserOutput{}, err return nil, types.DeactivateUserOutput{}, err
} }
err = r.iamSvc.OrganizationService.ArchiveUser(ctx, scope, input.OrganizationID, input.ProfileID) err = r.iamSvc.OrganizationService.DeactivateUser(ctx, scope, 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.ArchiveUserOutput{}, fmt.Errorf("user is managed by SCIM and cannot be archived: %w", err) return nil, types.DeactivateUserOutput{}, fmt.Errorf("user is managed by SCIM and cannot be deactivated: %w", err)
} }
if _, ok := errors.AsType[*iam.ErrLastActiveOwner](err); ok { if _, ok := errors.AsType[*iam.ErrLastActiveOwner](err); ok {
return nil, types.ArchiveUserOutput{}, fmt.Errorf("cannot archive last active owner: %w", err) return nil, types.DeactivateUserOutput{}, fmt.Errorf("cannot deactivate last active owner: %w", err)
} }
return nil, types.ArchiveUserOutput{}, fmt.Errorf("archive user: %w", err) return nil, types.DeactivateUserOutput{}, fmt.Errorf("deactivate user: %w", err)
} }
return nil, types.ArchiveUserOutput{ArchivedUserID: input.ProfileID}, nil return nil, types.DeactivateUserOutput{DeactivatedUserID: 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) {

View File

@@ -1698,7 +1698,7 @@ components:
$ref: "#/components/schemas/GID" $ref: "#/components/schemas/GID"
description: Deleted user (profile) ID description: Deleted user (profile) ID
ArchiveUserInput: DeactivateUserInput:
type: object type: object
required: required:
- organization_id - organization_id
@@ -1709,16 +1709,16 @@ 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 deactivate
ArchiveUserOutput: DeactivateUserOutput:
type: object type: object
required: required:
- archived_user_id - deactivated_user_id
properties: properties:
archived_user_id: deactivated_user_id:
$ref: "#/components/schemas/GID" $ref: "#/components/schemas/GID"
description: Archived user (profile) ID description: Deactivated user (profile) ID
GetProfileInput: GetProfileInput:
type: object type: object
@@ -12679,14 +12679,14 @@ tools:
$ref: "#/components/schemas/RemoveUserInput" $ref: "#/components/schemas/RemoveUserInput"
outputSchema: outputSchema:
$ref: "#/components/schemas/RemoveUserOutput" $ref: "#/components/schemas/RemoveUserOutput"
- name: archiveUser - name: deactivateUser
description: Archive a user in the organization description: Deactivate a user in the organization
hints: hints:
readonly: false readonly: false
inputSchema: inputSchema:
$ref: "#/components/schemas/ArchiveUserInput" $ref: "#/components/schemas/DeactivateUserInput"
outputSchema: outputSchema:
$ref: "#/components/schemas/ArchiveUserOutput" $ref: "#/components/schemas/DeactivateUserOutput"
- name: addThirdParty - name: addThirdParty
description: Add a new thirdParty to the organization description: Add a new thirdParty to the organization
hints: hints: