diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index 70e670bacc..dba3adf2b3 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -3174,6 +3174,33 @@ func TestServer_CreateInviteCode(t *testing.T) { }, }, }, + { + name: "recreate", + args: args{ + ctx: CTX, + req: &user.CreateInviteCodeRequest{}, + prepare: func(request *user.CreateInviteCodeRequest) error { + resp := Instance.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + _, err := Instance.Client.UserV2.CreateInviteCode(CTX, &user.CreateInviteCodeRequest{ + UserId: resp.GetUserId(), + Verification: &user.CreateInviteCodeRequest_SendCode{ + SendCode: &user.SendInviteCode{ + UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"), + ApplicationName: gu.Ptr("TestApp"), + }, + }, + }) + return err + }, + }, + want: &user.CreateInviteCodeResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, { name: "create, return code, ok", args: args{ diff --git a/internal/command/user_v2_invite.go b/internal/command/user_v2_invite.go index 1325d2e0c9..430ba8c7d1 100644 --- a/internal/command/user_v2_invite.go +++ b/internal/command/user_v2_invite.go @@ -19,14 +19,34 @@ type CreateUserInvite struct { URLTemplate string ReturnCode bool ApplicationName string + AuthRequestID string } func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvite) (details *domain.ObjectDetails, returnCode *string, err error) { + return c.sendInviteCode(ctx, invite, "", false) +} + +// ResendInviteCode resends the invite mail with a new code and an optional authRequestID. +// It will reuse the applicationName from the previous code. +func (c *Commands) ResendInviteCode(ctx context.Context, userID, resourceOwner, authRequestID string) (objectDetails *domain.ObjectDetails, err error) { + details, _, err := c.sendInviteCode( + ctx, + &CreateUserInvite{ + UserID: userID, + AuthRequestID: authRequestID, + }, + resourceOwner, + true, + ) + return details, err +} + +func (c *Commands) sendInviteCode(ctx context.Context, invite *CreateUserInvite, resourceOwner string, requireExisting bool) (details *domain.ObjectDetails, returnCode *string, err error) { invite.UserID = strings.TrimSpace(invite.UserID) if invite.UserID == "" { return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4jio3", "Errors.User.UserIDMissing") } - wm, err := c.userInviteCodeWriteModel(ctx, invite.UserID, "") + wm, err := c.userInviteCodeWriteModel(ctx, invite.UserID, resourceOwner) if err != nil { return nil, nil, err } @@ -39,10 +59,22 @@ func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvit if !wm.CreationAllowed() { return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-EF34g", "Errors.User.AlreadyInitialised") } + if requireExisting && wm.InviteCode == nil || wm.CodeReturned { + return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound") + } code, err := c.newUserInviteCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint if err != nil { return nil, nil, err } + if invite.URLTemplate == "" { + invite.URLTemplate = wm.URLTemplate + } + if invite.ApplicationName == "" { + invite.ApplicationName = wm.ApplicationName + } + if invite.AuthRequestID == "" { + invite.AuthRequestID = wm.AuthRequestID + } err = c.pushAppendAndReduce(ctx, wm, user.NewHumanInviteCodeAddedEvent( ctx, UserAggregateFromWriteModelCtx(ctx, &wm.WriteModel), @@ -51,7 +83,7 @@ func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvit invite.URLTemplate, invite.ReturnCode, invite.ApplicationName, - "", + invite.AuthRequestID, )) if err != nil { return nil, nil, err @@ -62,53 +94,6 @@ func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvit return writeModelToObjectDetails(&wm.WriteModel), returnCode, nil } -// ResendInviteCode resends the invite mail with a new code and an optional authRequestID. -// It will reuse the applicationName from the previous code. -func (c *Commands) ResendInviteCode(ctx context.Context, userID, resourceOwner, authRequestID string) (objectDetails *domain.ObjectDetails, err error) { - if userID == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-2n8vs", "Errors.User.UserIDMissing") - } - - existingCode, err := c.userInviteCodeWriteModel(ctx, userID, resourceOwner) - if err != nil { - return nil, err - } - if err := c.checkPermissionUpdateUser(ctx, existingCode.ResourceOwner, userID); err != nil { - return nil, err - } - if !existingCode.UserState.Exists() { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-H3b2a", "Errors.User.NotFound") - } - if !existingCode.CreationAllowed() { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Gg42s", "Errors.User.AlreadyInitialised") - } - if existingCode.InviteCode == nil || existingCode.CodeReturned { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound") - } - code, err := c.newUserInviteCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint - if err != nil { - return nil, err - } - if authRequestID == "" { - authRequestID = existingCode.AuthRequestID - } - err = c.pushAppendAndReduce(ctx, existingCode, - user.NewHumanInviteCodeAddedEvent( - ctx, - UserAggregateFromWriteModelCtx(ctx, &existingCode.WriteModel), - code.Crypted, - code.Expiry, - existingCode.URLTemplate, - false, - existingCode.ApplicationName, - authRequestID, - )) - if err != nil { - return nil, err - } - return writeModelToObjectDetails(&existingCode.WriteModel), nil -} - func (c *Commands) InviteCodeSent(ctx context.Context, userID, orgID string) (err error) { if userID == "" { return zerrors.ThrowInvalidArgument(nil, "COMMAND-Sgf31", "Errors.User.UserIDMissing") diff --git a/internal/command/user_v2_invite_model.go b/internal/command/user_v2_invite_model.go index 23f6322a19..6b2ab62e0d 100644 --- a/internal/command/user_v2_invite_model.go +++ b/internal/command/user_v2_invite_model.go @@ -28,7 +28,7 @@ type UserV2InviteWriteModel struct { } func (wm *UserV2InviteWriteModel) CreationAllowed() bool { - return !wm.EmailVerified && !wm.AuthMethodSet + return !wm.AuthMethodSet } func newUserV2InviteWriteModel(userID, orgID string) *UserV2InviteWriteModel { diff --git a/internal/command/user_v2_invite_test.go b/internal/command/user_v2_invite_test.go index efb57d86ad..8d49465778 100644 --- a/internal/command/user_v2_invite_test.go +++ b/internal/command/user_v2_invite_test.go @@ -11,7 +11,6 @@ import ( "go.uber.org/mock/gomock" "golang.org/x/text/language" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -316,7 +315,7 @@ func TestCommands_ResendInviteCode(t *testing.T) { userID: "", }, want{ - err: zerrors.ThrowInvalidArgument(nil, "COMMAND-2n8vs", "Errors.User.UserIDMissing"), + err: zerrors.ThrowInvalidArgument(nil, "COMMAND-4jio3", "Errors.User.UserIDMissing"), }, }, { @@ -348,7 +347,7 @@ func TestCommands_ResendInviteCode(t *testing.T) { userID: "unknown", }, want{ - err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-H3b2a", "Errors.User.NotFound"), + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wgvn4", "Errors.User.NotFound"), }, }, { @@ -566,76 +565,6 @@ func TestCommands_ResendInviteCode(t *testing.T) { }, }, }, - { - "resend with own user ok", - fields{ - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("userID", "org1").Aggregate, - "username", "firstName", - "lastName", - "nickName", - "displayName", - language.Afrikaans, - domain.GenderUnspecified, - "email", - false, - ), - ), - eventFromEventPusher( - user.NewHumanInviteCodeAddedEvent(context.Background(), - &user.NewAggregate("userID", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("code"), - }, - time.Hour, - "", - false, - "", - "authRequestID", - ), - ), - ), - expectPush( - eventFromEventPusher( - user.NewHumanInviteCodeAddedEvent(authz.NewMockContext("instanceID", "org1", "userID"), - &user.NewAggregate("userID", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("code"), - }, - time.Hour, - "", - false, - "", - "authRequestID2", - ), - ), - ), - ), - checkPermission: newMockPermissionCheckNotAllowed(), // user does not have permission, is allowed in the own context - newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour), - defaultSecretGenerators: &SecretGenerators{}, - }, - args{ - ctx: authz.NewMockContext("instanceID", "org1", "userID"), - userID: "userID", - authRequestID: "authRequestID2", - }, - want{ - details: &domain.ObjectDetails{ - ResourceOwner: "org1", - ID: "userID", - }, - }, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/proto/zitadel/user/v2/user.proto b/proto/zitadel/user/v2/user.proto index e2a140ea27..9ea2b8906e 100644 --- a/proto/zitadel/user/v2/user.proto +++ b/proto/zitadel/user/v2/user.proto @@ -334,7 +334,7 @@ message AuthFactorU2F { message SendInviteCode { // Optionally set a url_template, which will be used in the invite mail sent by ZITADEL to guide the user to your invitation page. - // If no template is set, the default ZITADEL url will be used. + // If no template is set and no previous code was created, the default ZITADEL url will be used. // // The following placeholders can be used: UserID, OrgID, Code optional string url_template = 1 [ @@ -346,7 +346,7 @@ message SendInviteCode { } ]; // Optionally set an application name, which will be used in the invite mail sent by ZITADEL. - // If no application name is set, ZITADEL will be used as default. + // If no application name is set and no previous code was created, ZITADEL will be used as default. optional string application_name = 2 [ (validate.rules).string = {min_len: 1, max_len: 200}, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 00cb352f70..6c842c0908 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -1135,6 +1135,8 @@ service UserService { // Create an invite code for a user // // Create an invite code for a user to initialize their first authentication method (password, passkeys, IdP) depending on the organization's available methods. + // If an invite code has been created previously, it's url template and application name will be used as defaults for the new code. + // The new code will overwrite the previous one and make it invalid. rpc CreateInviteCode (CreateInviteCodeRequest) returns (CreateInviteCodeResponse) { option (google.api.http) = { post: "/v2/users/{user_id}/invite_code" @@ -1158,6 +1160,8 @@ service UserService { // Resend an invite code for a user // + // Deprecated: Use [CreateInviteCode](apis/resources/user_service_v2/user-service-create-invite-code.api.mdx) instead. + // // Resend an invite code for a user to initialize their first authentication method (password, passkeys, IdP) depending on the organization's available methods. // A resend is only possible if a code has been created previously and sent to the user. If there is no code or it was directly returned, an error will be returned. rpc ResendInviteCode (ResendInviteCodeRequest) returns (ResendInviteCodeResponse) { @@ -1172,6 +1176,7 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200" value: {