Remove user from organization

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-03-14 11:17:07 +01:00
parent c2a93f6871
commit 7153831fb2
7 changed files with 498 additions and 2 deletions

View File

@@ -38,6 +38,7 @@ import {
import type { SettingsPageQuery as SettingsPageQueryType } from "./__generated__/SettingsPageQuery.graphql";
import type { SettingsPageUpdateOrganizationMutation as SettingsPageUpdateOrganizationMutationType } from "./__generated__/SettingsPageUpdateOrganizationMutation.graphql";
import type { SettingsPageInviteUserMutation as SettingsPageInviteUserMutationType } from "./__generated__/SettingsPageInviteUserMutation.graphql";
import type { SettingsPageRemoveUserMutation as SettingsPageRemoveUserMutationType } from "./__generated__/SettingsPageRemoveUserMutation.graphql";
const settingsPageQuery = graphql`
query SettingsPageQuery($organizationID: ID!) {
@@ -83,6 +84,14 @@ const inviteUserMutation = graphql`
}
`;
const removeUserMutation = graphql`
mutation SettingsPageRemoveUserMutation($input: RemoveUserInput!) {
removeUser(input: $input) {
success
}
}
`;
function SettingsPageContent({
queryRef,
}: {
@@ -103,6 +112,7 @@ function SettingsPageContent({
organization.name || ""
);
const [isUploading, setIsUploading] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const [updateOrganization] =
useMutation<SettingsPageUpdateOrganizationMutationType>(
@@ -112,6 +122,13 @@ function SettingsPageContent({
const [inviteUser] =
useMutation<SettingsPageInviteUserMutationType>(inviteUserMutation);
const [removeUser] =
useMutation<SettingsPageRemoveUserMutationType>(removeUserMutation);
const { organizationId } = useParams();
const [, loadQuery] =
useQueryLoader<SettingsPageQueryType>(settingsPageQuery);
const handleUpdateName = () => {
updateOrganization({
variables: {
@@ -232,6 +249,45 @@ function SettingsPageContent({
});
};
const handleRemoveUser = (userId: string) => {
setIsRemoving(true);
removeUser({
variables: {
input: {
organizationId: organization.id,
userId: userId,
},
},
onCompleted: (response) => {
setIsRemoving(false);
if (response.removeUser?.success) {
toast({
title: "User removed",
description: "The user has been removed from the organization.",
variant: "default",
});
// Refresh the query to update the UI
loadQuery({ organizationID: organizationId! });
} else {
toast({
title: "Error removing user",
description: "The user could not be removed. Please try again.",
variant: "destructive",
});
}
},
onError: (error) => {
setIsRemoving(false);
toast({
title: "Error removing user",
description: error.message,
variant: "destructive",
});
},
});
};
return (
<>
<div className="container mx-auto p-6 space-y-6">
@@ -377,8 +433,12 @@ function SettingsPageContent({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="text-red-600">
Remove member
<DropdownMenuItem
className="text-red-600"
onClick={() => handleRemoveUser(user.id)}
disabled={isRemoving}
>
{isRemoving ? "Removing..." : "Remove member"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -0,0 +1,93 @@
/**
* @generated SignedSource<<9d909d5fc7076593317b01aab165513d>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type RemoveUserInput = {
organizationId: string;
userId: string;
};
export type SettingsPageRemoveUserMutation$variables = {
input: RemoveUserInput;
};
export type SettingsPageRemoveUserMutation$data = {
readonly removeUser: {
readonly success: boolean;
};
};
export type SettingsPageRemoveUserMutation = {
response: SettingsPageRemoveUserMutation$data;
variables: SettingsPageRemoveUserMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "RemoveUserPayload",
"kind": "LinkedField",
"name": "removeUser",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "success",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "SettingsPageRemoveUserMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "SettingsPageRemoveUserMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "62588cd355b82b5a48d0b539a009b3bd",
"id": null,
"metadata": {},
"name": "SettingsPageRemoveUserMutation",
"operationKind": "mutation",
"text": "mutation SettingsPageRemoveUserMutation(\n $input: RemoveUserInput!\n) {\n removeUser(input: $input) {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "8d375bb2dfdab0f9d9520eee9f732b10";
export default node;

View File

@@ -388,6 +388,7 @@ type Mutation {
confirmEmail(input: ConfirmEmailInput!): ConfirmEmailPayload!
inviteUser(input: InviteUserInput!): InviteUserPayload!
confirmInvitation(input: ConfirmInvitationInput!): ConfirmInvitationPayload!
removeUser(input: RemoveUserInput!): RemoveUserPayload!
}
input CreateVendorInput {
@@ -733,3 +734,12 @@ input ConfirmInvitationInput {
type ConfirmInvitationPayload {
success: Boolean!
}
input RemoveUserInput {
organizationId: ID!
userId: ID!
}
type RemoveUserPayload {
success: Boolean!
}

View File

@@ -212,6 +212,7 @@ type ComplexityRoot struct {
DeleteVendor func(childComplexity int, input types.DeleteVendorInput) int
ImportFramework func(childComplexity int, input types.ImportFrameworkInput) int
InviteUser func(childComplexity int, input types.InviteUserInput) int
RemoveUser func(childComplexity int, input types.RemoveUserInput) int
UnassignTask func(childComplexity int, input types.UnassignTaskInput) int
UpdateControl func(childComplexity int, input types.UpdateControlInput) int
UpdateFramework func(childComplexity int, input types.UpdateFrameworkInput) int
@@ -301,6 +302,10 @@ type ComplexityRoot struct {
Viewer func(childComplexity int) int
}
RemoveUserPayload struct {
Success func(childComplexity int) int
}
Session struct {
ExpiresAt func(childComplexity int) int
ID func(childComplexity int) int
@@ -453,6 +458,7 @@ type MutationResolver interface {
ConfirmEmail(ctx context.Context, input types.ConfirmEmailInput) (*types.ConfirmEmailPayload, error)
InviteUser(ctx context.Context, input types.InviteUserInput) (*types.InviteUserPayload, error)
ConfirmInvitation(ctx context.Context, input types.ConfirmInvitationInput) (*types.ConfirmInvitationPayload, error)
RemoveUser(ctx context.Context, input types.RemoveUserInput) (*types.RemoveUserPayload, error)
}
type OrganizationResolver interface {
LogoURL(ctx context.Context, obj *types.Organization) (*string, error)
@@ -1107,6 +1113,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Mutation.InviteUser(childComplexity, args["input"].(types.InviteUserInput)), true
case "Mutation.removeUser":
if e.complexity.Mutation.RemoveUser == nil {
break
}
args, err := ec.field_Mutation_removeUser_args(context.TODO(), rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Mutation.RemoveUser(childComplexity, args["input"].(types.RemoveUserInput)), true
case "Mutation.unassignTask":
if e.complexity.Mutation.UnassignTask == nil {
break
@@ -1560,6 +1578,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Query.Viewer(childComplexity), true
case "RemoveUserPayload.success":
if e.complexity.RemoveUserPayload.Success == nil {
break
}
return e.complexity.RemoveUserPayload.Success(childComplexity), true
case "Session.expiresAt":
if e.complexity.Session.ExpiresAt == nil {
break
@@ -1974,6 +1999,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
ec.unmarshalInputDeleteVendorInput,
ec.unmarshalInputImportFrameworkInput,
ec.unmarshalInputInviteUserInput,
ec.unmarshalInputRemoveUserInput,
ec.unmarshalInputUnassignTaskInput,
ec.unmarshalInputUpdateControlInput,
ec.unmarshalInputUpdateFrameworkInput,
@@ -2470,6 +2496,7 @@ type Mutation {
confirmEmail(input: ConfirmEmailInput!): ConfirmEmailPayload!
inviteUser(input: InviteUserInput!): InviteUserPayload!
confirmInvitation(input: ConfirmInvitationInput!): ConfirmInvitationPayload!
removeUser(input: RemoveUserInput!): RemoveUserPayload!
}
input CreateVendorInput {
@@ -2815,6 +2842,15 @@ input ConfirmInvitationInput {
type ConfirmInvitationPayload {
success: Boolean!
}
input RemoveUserInput {
organizationId: ID!
userId: ID!
}
type RemoveUserPayload {
success: Boolean!
}
`, BuiltIn: false},
}
var parsedSchema = gqlparser.MustLoadSchema(sources...)
@@ -3391,6 +3427,29 @@ func (ec *executionContext) field_Mutation_inviteUser_argsInput(
return zeroVal, nil
}
func (ec *executionContext) field_Mutation_removeUser_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
arg0, err := ec.field_Mutation_removeUser_argsInput(ctx, rawArgs)
if err != nil {
return nil, err
}
args["input"] = arg0
return args, nil
}
func (ec *executionContext) field_Mutation_removeUser_argsInput(
ctx context.Context,
rawArgs map[string]any,
) (types.RemoveUserInput, error) {
ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input"))
if tmp, ok := rawArgs["input"]; ok {
return ec.unmarshalNRemoveUserInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐRemoveUserInput(ctx, tmp)
}
var zeroVal types.RemoveUserInput
return zeroVal, nil
}
func (ec *executionContext) field_Mutation_unassignTask_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
@@ -7838,6 +7897,53 @@ func (ec *executionContext) fieldContext_Mutation_confirmInvitation(ctx context.
return fc, nil
}
func (ec *executionContext) _Mutation_removeUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Mutation_removeUser(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().RemoveUser(rctx, fc.Args["input"].(types.RemoveUserInput))
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(*types.RemoveUserPayload)
fc.Result = res
return ec.marshalNRemoveUserPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐRemoveUserPayload(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_Mutation_removeUser(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Mutation",
Field: field,
IsMethod: true,
IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "success":
return ec.fieldContext_RemoveUserPayload_success(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type RemoveUserPayload", field.Name)
},
}
ctx = graphql.WithFieldContext(ctx, fc)
if fc.Args, err = ec.field_Mutation_removeUser_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
ec.Error(ctx, err)
return fc, err
}
return fc, nil
}
func (ec *executionContext) _Organization_id(ctx context.Context, field graphql.CollectedField, obj *types.Organization) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Organization_id(ctx, field)
if err != nil {
@@ -9843,6 +9949,44 @@ func (ec *executionContext) fieldContext_Query___schema(_ context.Context, field
return fc, nil
}
func (ec *executionContext) _RemoveUserPayload_success(ctx context.Context, field graphql.CollectedField, obj *types.RemoveUserPayload) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_RemoveUserPayload_success(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.Success, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(bool)
fc.Result = res
return ec.marshalNBoolean2bool(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_RemoveUserPayload_success(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "RemoveUserPayload",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Boolean does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _Session_id(ctx context.Context, field graphql.CollectedField, obj *types.Session) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Session_id(ctx, field)
if err != nil {
@@ -14634,6 +14778,40 @@ func (ec *executionContext) unmarshalInputInviteUserInput(ctx context.Context, o
return it, nil
}
func (ec *executionContext) unmarshalInputRemoveUserInput(ctx context.Context, obj any) (types.RemoveUserInput, error) {
var it types.RemoveUserInput
asMap := map[string]any{}
for k, v := range obj.(map[string]any) {
asMap[k] = v
}
fieldsInOrder := [...]string{"organizationId", "userId"}
for _, k := range fieldsInOrder {
v, ok := asMap[k]
if !ok {
continue
}
switch k {
case "organizationId":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("organizationId"))
data, err := ec.unmarshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, v)
if err != nil {
return it, err
}
it.OrganizationID = data
case "userId":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("userId"))
data, err := ec.unmarshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, v)
if err != nil {
return it, err
}
it.UserID = data
}
}
return it, nil
}
func (ec *executionContext) unmarshalInputUnassignTaskInput(ctx context.Context, obj any) (types.UnassignTaskInput, error) {
var it types.UnassignTaskInput
asMap := map[string]any{}
@@ -16709,6 +16887,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "removeUser":
out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
return ec._Mutation_removeUser(ctx, field)
})
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
@@ -17541,6 +17726,45 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
return out
}
var removeUserPayloadImplementors = []string{"RemoveUserPayload"}
func (ec *executionContext) _RemoveUserPayload(ctx context.Context, sel ast.SelectionSet, obj *types.RemoveUserPayload) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, removeUserPayloadImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("RemoveUserPayload")
case "success":
out.Values[i] = ec._RemoveUserPayload_success(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.processDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var sessionImplementors = []string{"Session"}
func (ec *executionContext) _Session(ctx context.Context, sel ast.SelectionSet, obj *types.Session) graphql.Marshaler {
@@ -19920,6 +20144,25 @@ var (
}
)
func (ec *executionContext) unmarshalNRemoveUserInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐRemoveUserInput(ctx context.Context, v any) (types.RemoveUserInput, error) {
res, err := ec.unmarshalInputRemoveUserInput(ctx, v)
return res, graphql.ErrorOnPath(ctx, err)
}
func (ec *executionContext) marshalNRemoveUserPayload2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐRemoveUserPayload(ctx context.Context, sel ast.SelectionSet, v types.RemoveUserPayload) graphql.Marshaler {
return ec._RemoveUserPayload(ctx, sel, &v)
}
func (ec *executionContext) marshalNRemoveUserPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐRemoveUserPayload(ctx context.Context, sel ast.SelectionSet, v *types.RemoveUserPayload) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "the requested element is null which the schema does not allow")
}
return graphql.Null
}
return ec._RemoveUserPayload(ctx, sel, v)
}
func (ec *executionContext) unmarshalNRiskTier2githubᚗcomᚋgetproboᚋproboᚋpkgᚋcoredataᚐRiskTier(ctx context.Context, v any) (coredata.RiskTier, error) {
tmp, err := graphql.UnmarshalString(v)
res := unmarshalNRiskTier2githubᚗcomᚋgetproboᚋproboᚋpkgᚋcoredataᚐRiskTier[tmp]

View File

@@ -354,6 +354,15 @@ type PolicyEdge struct {
type Query struct {
}
type RemoveUserInput struct {
OrganizationID gid.GID `json:"organizationId"`
UserID gid.GID `json:"userId"`
}
type RemoveUserPayload struct {
Success bool `json:"success"`
}
type Session struct {
ID gid.GID `json:"id"`
ExpiresAt time.Time `json:"expiresAt"`

View File

@@ -543,6 +543,29 @@ func (r *mutationResolver) ConfirmInvitation(ctx context.Context, input types.Co
return &types.ConfirmInvitationPayload{Success: true}, nil
}
// RemoveUser is the resolver for the removeUser field.
func (r *mutationResolver) RemoveUser(ctx context.Context, input types.RemoveUserInput) (*types.RemoveUserPayload, error) {
user := UserFromContext(ctx)
organizations, err := r.usrmgrSvc.ListOrganizationsForUserID(ctx, user.ID)
if err != nil {
panic(fmt.Errorf("failed to list organizations for user: %w", err))
}
for _, organization := range organizations {
if organization.ID == input.OrganizationID {
err := r.usrmgrSvc.RemoveUser(ctx, input.OrganizationID, input.UserID)
if err != nil {
return nil, err
}
return &types.RemoveUserPayload{Success: true}, nil
}
}
return nil, fmt.Errorf("organization not found")
}
// LogoURL is the resolver for the logoUrl field.
func (r *organizationResolver) LogoURL(ctx context.Context, obj *types.Organization) (*string, error) {
svc := r.GetTenantServiceIfAuthorized(ctx, obj.ID.TenantID())

View File

@@ -542,6 +542,46 @@ func (s Service) InviteUser(
return &ErrInvalidFullName{fullName}
}
var userExists bool
err := s.pg.WithConn(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
if err := user.LoadByEmail(ctx, tx, emailAddress); err != nil {
var errUserNotFound *coredata.ErrUserNotFound
if errors.As(err, &errUserNotFound) {
userExists = false
return nil
}
return fmt.Errorf("cannot load user by email: %w", err)
}
userExists = true
uo := coredata.UserOrganization{
UserID: user.ID,
OrganizationID: organizationID,
CreatedAt: time.Now(),
}
if err := uo.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert user organization: %w", err)
}
return nil
},
)
if err != nil {
return err
}
if userExists {
return nil
}
confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeOrganizationInvitation,
@@ -640,3 +680,21 @@ func (s Service) ConfirmInvitation(ctx context.Context, tokenString string, pass
},
)
}
func (s Service) RemoveUser(ctx context.Context, organizationID gid.GID, userID gid.GID) error {
return s.pg.WithConn(
ctx,
func(tx pg.Conn) error {
uo := coredata.UserOrganization{
UserID: userID,
OrganizationID: organizationID,
}
if err := uo.Delete(ctx, tx); err != nil {
return fmt.Errorf("cannot delete user organization: %w", err)
}
return nil
},
)
}