mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	Add team member invite by email (#20307)
Allows to add (not registered) team members by email. related #5353 Invite by mail:  Pending invitations:  Email:  Join form:  Co-authored-by: Jack Hay <jjphay@gmail.com>
This commit is contained in:
		| @@ -417,6 +417,8 @@ var migrations = []Migration{ | ||||
| 	NewMigration("Conan and generic packages do not need to be semantically versioned", fixPackageSemverField), | ||||
| 	// v227 -> v228 | ||||
| 	NewMigration("Create key/value table for system settings", createSystemSettingsTable), | ||||
| 	// v228 -> v229 | ||||
| 	NewMigration("Add TeamInvite table", addTeamInviteTable), | ||||
| } | ||||
|  | ||||
| // GetCurrentDBVersion returns the current db version | ||||
|   | ||||
							
								
								
									
										26
									
								
								models/migrations/v228.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								models/migrations/v228.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| // Copyright 2022 The Gitea Authors. All rights reserved. | ||||
| // Use of this source code is governed by a MIT-style | ||||
| // license that can be found in the LICENSE file. | ||||
|  | ||||
| package migrations | ||||
|  | ||||
| import ( | ||||
| 	"code.gitea.io/gitea/modules/timeutil" | ||||
|  | ||||
| 	"xorm.io/xorm" | ||||
| ) | ||||
|  | ||||
| func addTeamInviteTable(x *xorm.Engine) error { | ||||
| 	type TeamInvite struct { | ||||
| 		ID          int64              `xorm:"pk autoincr"` | ||||
| 		Token       string             `xorm:"UNIQUE(token) INDEX NOT NULL DEFAULT ''"` | ||||
| 		InviterID   int64              `xorm:"NOT NULL DEFAULT 0"` | ||||
| 		OrgID       int64              `xorm:"INDEX NOT NULL DEFAULT 0"` | ||||
| 		TeamID      int64              `xorm:"UNIQUE(team_mail) INDEX NOT NULL DEFAULT 0"` | ||||
| 		Email       string             `xorm:"UNIQUE(team_mail) NOT NULL DEFAULT ''"` | ||||
| 		CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` | ||||
| 		UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` | ||||
| 	} | ||||
|  | ||||
| 	return x.Sync2(new(TeamInvite)) | ||||
| } | ||||
| @@ -431,25 +431,15 @@ func DeleteTeam(t *organization.Team) error { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Delete team-user. | ||||
| 	if _, err := sess. | ||||
| 		Where("org_id=?", t.OrgID). | ||||
| 		Where("team_id=?", t.ID). | ||||
| 		Delete(new(organization.TeamUser)); err != nil { | ||||
| 	if err := db.DeleteBeans(ctx, | ||||
| 		&organization.Team{ID: t.ID}, | ||||
| 		&organization.TeamUser{OrgID: t.OrgID, TeamID: t.ID}, | ||||
| 		&organization.TeamUnit{TeamID: t.ID}, | ||||
| 		&organization.TeamInvite{TeamID: t.ID}, | ||||
| 	); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	// Delete team-unit. | ||||
| 	if _, err := sess. | ||||
| 		Where("team_id=?", t.ID). | ||||
| 		Delete(new(organization.TeamUnit)); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	// Delete team. | ||||
| 	if _, err := sess.ID(t.ID).Delete(new(organization.Team)); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	// Update organization number of teams. | ||||
| 	if _, err := sess.Exec("UPDATE `user` SET num_teams=num_teams-1 WHERE id=?", t.OrgID); err != nil { | ||||
| 		return err | ||||
|   | ||||
| @@ -370,8 +370,9 @@ func DeleteOrganization(ctx context.Context, org *Organization) error { | ||||
| 		&OrgUser{OrgID: org.ID}, | ||||
| 		&TeamUser{OrgID: org.ID}, | ||||
| 		&TeamUnit{OrgID: org.ID}, | ||||
| 		&TeamInvite{OrgID: org.ID}, | ||||
| 	); err != nil { | ||||
| 		return fmt.Errorf("deleteBeans: %v", err) | ||||
| 		return fmt.Errorf("DeleteBeans: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	if _, err := db.GetEngine(ctx).ID(org.ID).Delete(new(user_model.User)); err != nil { | ||||
|   | ||||
| @@ -94,6 +94,7 @@ func init() { | ||||
| 	db.RegisterModel(new(TeamUser)) | ||||
| 	db.RegisterModel(new(TeamRepo)) | ||||
| 	db.RegisterModel(new(TeamUnit)) | ||||
| 	db.RegisterModel(new(TeamInvite)) | ||||
| } | ||||
|  | ||||
| // SearchTeamOptions holds the search options | ||||
|   | ||||
							
								
								
									
										162
									
								
								models/organization/team_invite.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								models/organization/team_invite.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,162 @@ | ||||
| // Copyright 2022 The Gitea Authors. All rights reserved. | ||||
| // Use of this source code is governed by a MIT-style | ||||
| // license that can be found in the LICENSE file. | ||||
|  | ||||
| package organization | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
|  | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| 	user_model "code.gitea.io/gitea/models/user" | ||||
| 	"code.gitea.io/gitea/modules/timeutil" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
|  | ||||
| 	"xorm.io/builder" | ||||
| ) | ||||
|  | ||||
| type ErrTeamInviteAlreadyExist struct { | ||||
| 	TeamID int64 | ||||
| 	Email  string | ||||
| } | ||||
|  | ||||
| func IsErrTeamInviteAlreadyExist(err error) bool { | ||||
| 	_, ok := err.(ErrTeamInviteAlreadyExist) | ||||
| 	return ok | ||||
| } | ||||
|  | ||||
| func (err ErrTeamInviteAlreadyExist) Error() string { | ||||
| 	return fmt.Sprintf("team invite already exists [team_id: %d, email: %s]", err.TeamID, err.Email) | ||||
| } | ||||
|  | ||||
| func (err ErrTeamInviteAlreadyExist) Unwrap() error { | ||||
| 	return util.ErrAlreadyExist | ||||
| } | ||||
|  | ||||
| type ErrTeamInviteNotFound struct { | ||||
| 	Token string | ||||
| } | ||||
|  | ||||
| func IsErrTeamInviteNotFound(err error) bool { | ||||
| 	_, ok := err.(ErrTeamInviteNotFound) | ||||
| 	return ok | ||||
| } | ||||
|  | ||||
| func (err ErrTeamInviteNotFound) Error() string { | ||||
| 	return fmt.Sprintf("team invite was not found [token: %s]", err.Token) | ||||
| } | ||||
|  | ||||
| func (err ErrTeamInviteNotFound) Unwrap() error { | ||||
| 	return util.ErrNotExist | ||||
| } | ||||
|  | ||||
| // ErrUserEmailAlreadyAdded represents a "user by email already added to team" error. | ||||
| type ErrUserEmailAlreadyAdded struct { | ||||
| 	Email string | ||||
| } | ||||
|  | ||||
| // IsErrUserEmailAlreadyAdded checks if an error is a ErrUserEmailAlreadyAdded. | ||||
| func IsErrUserEmailAlreadyAdded(err error) bool { | ||||
| 	_, ok := err.(ErrUserEmailAlreadyAdded) | ||||
| 	return ok | ||||
| } | ||||
|  | ||||
| func (err ErrUserEmailAlreadyAdded) Error() string { | ||||
| 	return fmt.Sprintf("user with email already added [email: %s]", err.Email) | ||||
| } | ||||
|  | ||||
| func (err ErrUserEmailAlreadyAdded) Unwrap() error { | ||||
| 	return util.ErrAlreadyExist | ||||
| } | ||||
|  | ||||
| // TeamInvite represents an invite to a team | ||||
| type TeamInvite struct { | ||||
| 	ID          int64              `xorm:"pk autoincr"` | ||||
| 	Token       string             `xorm:"UNIQUE(token) INDEX NOT NULL DEFAULT ''"` | ||||
| 	InviterID   int64              `xorm:"NOT NULL DEFAULT 0"` | ||||
| 	OrgID       int64              `xorm:"INDEX NOT NULL DEFAULT 0"` | ||||
| 	TeamID      int64              `xorm:"UNIQUE(team_mail) INDEX NOT NULL DEFAULT 0"` | ||||
| 	Email       string             `xorm:"UNIQUE(team_mail) NOT NULL DEFAULT ''"` | ||||
| 	CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` | ||||
| 	UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` | ||||
| } | ||||
|  | ||||
| func CreateTeamInvite(ctx context.Context, doer *user_model.User, team *Team, email string) (*TeamInvite, error) { | ||||
| 	has, err := db.GetEngine(ctx).Exist(&TeamInvite{ | ||||
| 		TeamID: team.ID, | ||||
| 		Email:  email, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	if has { | ||||
| 		return nil, ErrTeamInviteAlreadyExist{ | ||||
| 			TeamID: team.ID, | ||||
| 			Email:  email, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// check if the user is already a team member by email | ||||
| 	exist, err := db.GetEngine(ctx). | ||||
| 		Where(builder.Eq{ | ||||
| 			"team_user.org_id":  team.OrgID, | ||||
| 			"team_user.team_id": team.ID, | ||||
| 			"`user`.email":      email, | ||||
| 		}). | ||||
| 		Join("INNER", "`user`", "`user`.id = team_user.uid"). | ||||
| 		Table("team_user"). | ||||
| 		Exist() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	if exist { | ||||
| 		return nil, ErrUserEmailAlreadyAdded{ | ||||
| 			Email: email, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	token, err := util.CryptoRandomString(25) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	invite := &TeamInvite{ | ||||
| 		Token:     token, | ||||
| 		InviterID: doer.ID, | ||||
| 		OrgID:     team.OrgID, | ||||
| 		TeamID:    team.ID, | ||||
| 		Email:     email, | ||||
| 	} | ||||
|  | ||||
| 	return invite, db.Insert(ctx, invite) | ||||
| } | ||||
|  | ||||
| func RemoveInviteByID(ctx context.Context, inviteID, teamID int64) error { | ||||
| 	_, err := db.DeleteByBean(ctx, &TeamInvite{ | ||||
| 		ID:     inviteID, | ||||
| 		TeamID: teamID, | ||||
| 	}) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func GetInvitesByTeamID(ctx context.Context, teamID int64) ([]*TeamInvite, error) { | ||||
| 	invites := make([]*TeamInvite, 0, 10) | ||||
| 	return invites, db.GetEngine(ctx). | ||||
| 		Where("team_id=?", teamID). | ||||
| 		Find(&invites) | ||||
| } | ||||
|  | ||||
| func GetInviteByToken(ctx context.Context, token string) (*TeamInvite, error) { | ||||
| 	invite := &TeamInvite{} | ||||
|  | ||||
| 	has, err := db.GetEngine(ctx).Where("token=?", token).Get(invite) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	if !has { | ||||
| 		return nil, ErrTeamInviteNotFound{Token: token} | ||||
| 	} | ||||
| 	return invite, nil | ||||
| } | ||||
							
								
								
									
										49
									
								
								models/organization/team_invite_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								models/organization/team_invite_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| // Copyright 2022 The Gitea Authors. All rights reserved. | ||||
| // Use of this source code is governed by a MIT-style | ||||
| // license that can be found in the LICENSE file. | ||||
|  | ||||
| package organization_test | ||||
|  | ||||
| import ( | ||||
| 	"testing" | ||||
|  | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| 	"code.gitea.io/gitea/models/organization" | ||||
| 	"code.gitea.io/gitea/models/unittest" | ||||
| 	user_model "code.gitea.io/gitea/models/user" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
|  | ||||
| func TestTeamInvite(t *testing.T) { | ||||
| 	assert.NoError(t, unittest.PrepareTestDatabase()) | ||||
|  | ||||
| 	team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}) | ||||
|  | ||||
| 	t.Run("MailExistsInTeam", func(t *testing.T) { | ||||
| 		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) | ||||
|  | ||||
| 		// user 2 already added to team 2, should result in error | ||||
| 		_, err := organization.CreateTeamInvite(db.DefaultContext, user2, team, user2.Email) | ||||
| 		assert.Error(t, err) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("CreateAndRemove", func(t *testing.T) { | ||||
| 		user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) | ||||
|  | ||||
| 		invite, err := organization.CreateTeamInvite(db.DefaultContext, user1, team, "user3@example.com") | ||||
| 		assert.NotNil(t, invite) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		// Shouldn't allow duplicate invite | ||||
| 		_, err = organization.CreateTeamInvite(db.DefaultContext, user1, team, "user3@example.com") | ||||
| 		assert.Error(t, err) | ||||
|  | ||||
| 		// should remove invite | ||||
| 		assert.NoError(t, organization.RemoveInviteByID(db.DefaultContext, invite.ID, invite.TeamID)) | ||||
|  | ||||
| 		// invite should not exist | ||||
| 		_, err = organization.GetInviteByToken(db.DefaultContext, invite.Token) | ||||
| 		assert.Error(t, err) | ||||
| 	}) | ||||
| } | ||||
| @@ -412,6 +412,11 @@ repo.transfer.body = To accept or reject it visit %s or just ignore it. | ||||
| repo.collaborator.added.subject = %s added you to %s | ||||
| repo.collaborator.added.text = You have been added as a collaborator of repository: | ||||
|  | ||||
| team_invite.subject = %[1]s has invited you to join the %[2]s organization | ||||
| team_invite.text_1 = %[1]s has invited you to join team %[2]s in organization %[3]s. | ||||
| team_invite.text_2 = Please click the following link to join the team: | ||||
| team_invite.text_3 = Note: This invitation was intended for %[1]s. If you were not expecting this invitation, you can ignore this email. | ||||
|  | ||||
| [modal] | ||||
| yes = Yes | ||||
| no = No | ||||
| @@ -487,6 +492,7 @@ user_not_exist = The user does not exist. | ||||
| team_not_exist = The team does not exist. | ||||
| last_org_owner = You cannot remove the last user from the 'owners' team. There must be at least one owner for an organization. | ||||
| cannot_add_org_to_team = An organization cannot be added as a team member. | ||||
| duplicate_invite_to_team = The user was already invited as a team member. | ||||
|  | ||||
| invalid_ssh_key = Can not verify your SSH key: %s | ||||
| invalid_gpg_key = Can not verify your GPG key: %s | ||||
| @@ -2402,6 +2408,8 @@ teams.members = Team Members | ||||
| teams.update_settings = Update Settings | ||||
| teams.delete_team = Delete Team | ||||
| teams.add_team_member = Add Team Member | ||||
| teams.invite_team_member = Invite to %s | ||||
| teams.invite_team_member.list = Pending Invitations | ||||
| teams.delete_team_title = Delete Team | ||||
| teams.delete_team_desc = Deleting a team revokes repository access from its members. Continue? | ||||
| teams.delete_team_success = The team has been deleted. | ||||
| @@ -2426,6 +2434,9 @@ teams.all_repositories_helper = Team has access to all repositories. Selecting t | ||||
| teams.all_repositories_read_permission_desc = This team grants <strong>Read</strong> access to <strong>all repositories</strong>: members can view and clone repositories. | ||||
| teams.all_repositories_write_permission_desc = This team grants <strong>Write</strong> access to <strong>all repositories</strong>: members can read from and push to repositories. | ||||
| teams.all_repositories_admin_permission_desc = This team grants <strong>Admin</strong> access to <strong>all repositories</strong>: members can read from, push to and add collaborators to repositories. | ||||
| teams.invite.title = You've been invited to join team <strong>%s</strong> in organization <strong>%s</strong>. | ||||
| teams.invite.by = Invited by %s | ||||
| teams.invite.description = Please click the button below to join the team. | ||||
|  | ||||
| [admin] | ||||
| dashboard = Dashboard | ||||
|   | ||||
| @@ -14,7 +14,7 @@ import ( | ||||
|  | ||||
| 	"code.gitea.io/gitea/models" | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| 	"code.gitea.io/gitea/models/organization" | ||||
| 	org_model "code.gitea.io/gitea/models/organization" | ||||
| 	"code.gitea.io/gitea/models/perm" | ||||
| 	repo_model "code.gitea.io/gitea/models/repo" | ||||
| 	unit_model "code.gitea.io/gitea/models/unit" | ||||
| @@ -23,9 +23,11 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/context" | ||||
| 	"code.gitea.io/gitea/modules/convert" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/web" | ||||
| 	"code.gitea.io/gitea/routers/utils" | ||||
| 	"code.gitea.io/gitea/services/forms" | ||||
| 	"code.gitea.io/gitea/services/mailer" | ||||
| 	org_service "code.gitea.io/gitea/services/org" | ||||
| ) | ||||
|  | ||||
| @@ -38,6 +40,8 @@ const ( | ||||
| 	tplTeamMembers base.TplName = "org/team/members" | ||||
| 	// tplTeamRepositories template path for showing team repositories page | ||||
| 	tplTeamRepositories base.TplName = "org/team/repositories" | ||||
| 	// tplTeamInvite template path for team invites page | ||||
| 	tplTeamInvite base.TplName = "org/team/invite" | ||||
| ) | ||||
|  | ||||
| // Teams render teams list page | ||||
| @@ -59,12 +63,6 @@ func Teams(ctx *context.Context) { | ||||
|  | ||||
| // TeamsAction response for join, leave, remove, add operations to team | ||||
| func TeamsAction(ctx *context.Context) { | ||||
| 	uid := ctx.FormInt64("uid") | ||||
| 	if uid == 0 { | ||||
| 		ctx.Redirect(ctx.Org.OrgLink + "/teams") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	page := ctx.FormString("page") | ||||
| 	var err error | ||||
| 	switch ctx.Params(":action") { | ||||
| @@ -77,7 +75,7 @@ func TeamsAction(ctx *context.Context) { | ||||
| 	case "leave": | ||||
| 		err = models.RemoveTeamMember(ctx.Org.Team, ctx.Doer.ID) | ||||
| 		if err != nil { | ||||
| 			if organization.IsErrLastOrgOwner(err) { | ||||
| 			if org_model.IsErrLastOrgOwner(err) { | ||||
| 				ctx.Flash.Error(ctx.Tr("form.last_org_owner")) | ||||
| 			} else { | ||||
| 				log.Error("Action(%s): %v", ctx.Params(":action"), err) | ||||
| @@ -98,9 +96,16 @@ func TeamsAction(ctx *context.Context) { | ||||
| 			ctx.Error(http.StatusNotFound) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		uid := ctx.FormInt64("uid") | ||||
| 		if uid == 0 { | ||||
| 			ctx.Redirect(ctx.Org.OrgLink + "/teams") | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		err = models.RemoveTeamMember(ctx.Org.Team, uid) | ||||
| 		if err != nil { | ||||
| 			if organization.IsErrLastOrgOwner(err) { | ||||
| 			if org_model.IsErrLastOrgOwner(err) { | ||||
| 				ctx.Flash.Error(ctx.Tr("form.last_org_owner")) | ||||
| 			} else { | ||||
| 				log.Error("Action(%s): %v", ctx.Params(":action"), err) | ||||
| @@ -126,10 +131,27 @@ func TeamsAction(ctx *context.Context) { | ||||
| 		u, err = user_model.GetUserByName(ctx, uname) | ||||
| 		if err != nil { | ||||
| 			if user_model.IsErrUserNotExist(err) { | ||||
| 				ctx.Flash.Error(ctx.Tr("form.user_not_exist")) | ||||
| 				if setting.MailService != nil && user_model.ValidateEmail(uname) == nil { | ||||
| 					invite, err := org_model.CreateTeamInvite(ctx, ctx.Doer, ctx.Org.Team, uname) | ||||
| 					if err != nil { | ||||
| 						if org_model.IsErrTeamInviteAlreadyExist(err) { | ||||
| 							ctx.Flash.Error(ctx.Tr("form.duplicate_invite_to_team")) | ||||
| 						} else if org_model.IsErrUserEmailAlreadyAdded(err) { | ||||
| 							ctx.Flash.Error(ctx.Tr("org.teams.add_duplicate_users")) | ||||
| 						} else { | ||||
| 							ctx.ServerError("CreateTeamInvite", err) | ||||
| 							return | ||||
| 						} | ||||
| 					} else if err := mailer.MailTeamInvite(ctx, ctx.Doer, ctx.Org.Team, invite); err != nil { | ||||
| 						ctx.ServerError("MailTeamInvite", err) | ||||
| 						return | ||||
| 					} | ||||
| 				} else { | ||||
| 					ctx.Flash.Error(ctx.Tr("form.user_not_exist")) | ||||
| 				} | ||||
| 				ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName)) | ||||
| 			} else { | ||||
| 				ctx.ServerError(" GetUserByName", err) | ||||
| 				ctx.ServerError("GetUserByName", err) | ||||
| 			} | ||||
| 			return | ||||
| 		} | ||||
| @@ -146,11 +168,30 @@ func TeamsAction(ctx *context.Context) { | ||||
| 			err = models.AddTeamMember(ctx.Org.Team, u.ID) | ||||
| 		} | ||||
|  | ||||
| 		page = "team" | ||||
| 	case "remove_invite": | ||||
| 		if !ctx.Org.IsOwner { | ||||
| 			ctx.Error(http.StatusNotFound) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		iid := ctx.FormInt64("iid") | ||||
| 		if iid == 0 { | ||||
| 			ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName)) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		if err := org_model.RemoveInviteByID(ctx, iid, ctx.Org.Team.ID); err != nil { | ||||
| 			log.Error("Action(%s): %v", ctx.Params(":action"), err) | ||||
| 			ctx.ServerError("RemoveInviteByID", err) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		page = "team" | ||||
| 	} | ||||
|  | ||||
| 	if err != nil { | ||||
| 		if organization.IsErrLastOrgOwner(err) { | ||||
| 		if org_model.IsErrLastOrgOwner(err) { | ||||
| 			ctx.Flash.Error(ctx.Tr("form.last_org_owner")) | ||||
| 		} else { | ||||
| 			log.Error("Action(%s): %v", ctx.Params(":action"), err) | ||||
| @@ -224,7 +265,7 @@ func NewTeam(ctx *context.Context) { | ||||
| 	ctx.Data["Title"] = ctx.Org.Organization.FullName | ||||
| 	ctx.Data["PageIsOrgTeams"] = true | ||||
| 	ctx.Data["PageIsOrgTeamsNew"] = true | ||||
| 	ctx.Data["Team"] = &organization.Team{} | ||||
| 	ctx.Data["Team"] = &org_model.Team{} | ||||
| 	ctx.Data["Units"] = unit_model.Units | ||||
| 	ctx.HTML(http.StatusOK, tplTeamNew) | ||||
| } | ||||
| @@ -255,7 +296,7 @@ func NewTeamPost(ctx *context.Context) { | ||||
| 		p = unit_model.MinUnitAccessMode(unitPerms) | ||||
| 	} | ||||
|  | ||||
| 	t := &organization.Team{ | ||||
| 	t := &org_model.Team{ | ||||
| 		OrgID:                   ctx.Org.Organization.ID, | ||||
| 		Name:                    form.TeamName, | ||||
| 		Description:             form.Description, | ||||
| @@ -265,9 +306,9 @@ func NewTeamPost(ctx *context.Context) { | ||||
| 	} | ||||
|  | ||||
| 	if t.AccessMode < perm.AccessModeAdmin { | ||||
| 		units := make([]*organization.TeamUnit, 0, len(unitPerms)) | ||||
| 		units := make([]*org_model.TeamUnit, 0, len(unitPerms)) | ||||
| 		for tp, perm := range unitPerms { | ||||
| 			units = append(units, &organization.TeamUnit{ | ||||
| 			units = append(units, &org_model.TeamUnit{ | ||||
| 				OrgID:      ctx.Org.Organization.ID, | ||||
| 				Type:       tp, | ||||
| 				AccessMode: perm, | ||||
| @@ -295,7 +336,7 @@ func NewTeamPost(ctx *context.Context) { | ||||
| 	if err := models.NewTeam(t); err != nil { | ||||
| 		ctx.Data["Err_TeamName"] = true | ||||
| 		switch { | ||||
| 		case organization.IsErrTeamAlreadyExist(err): | ||||
| 		case org_model.IsErrTeamAlreadyExist(err): | ||||
| 			ctx.RenderWithErr(ctx.Tr("form.team_name_been_taken"), tplTeamNew, &form) | ||||
| 		default: | ||||
| 			ctx.ServerError("NewTeam", err) | ||||
| @@ -316,6 +357,15 @@ func TeamMembers(ctx *context.Context) { | ||||
| 		return | ||||
| 	} | ||||
| 	ctx.Data["Units"] = unit_model.Units | ||||
|  | ||||
| 	invites, err := org_model.GetInvitesByTeamID(ctx, ctx.Org.Team.ID) | ||||
| 	if err != nil { | ||||
| 		ctx.ServerError("GetInvitesByTeamID", err) | ||||
| 		return | ||||
| 	} | ||||
| 	ctx.Data["Invites"] = invites | ||||
| 	ctx.Data["IsEmailInviteEnabled"] = setting.MailService != nil | ||||
|  | ||||
| 	ctx.HTML(http.StatusOK, tplTeamMembers) | ||||
| } | ||||
|  | ||||
| @@ -339,7 +389,7 @@ func SearchTeam(ctx *context.Context) { | ||||
| 		PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), | ||||
| 	} | ||||
|  | ||||
| 	opts := &organization.SearchTeamOptions{ | ||||
| 	opts := &org_model.SearchTeamOptions{ | ||||
| 		// UserID is not set because the router already requires the doer to be an org admin. Thus, we don't need to restrict to teams that the user belongs in | ||||
| 		Keyword:     ctx.FormTrim("q"), | ||||
| 		OrgID:       ctx.Org.Organization.ID, | ||||
| @@ -347,7 +397,7 @@ func SearchTeam(ctx *context.Context) { | ||||
| 		ListOptions: listOptions, | ||||
| 	} | ||||
|  | ||||
| 	teams, maxResults, err := organization.SearchTeam(opts) | ||||
| 	teams, maxResults, err := org_model.SearchTeam(opts) | ||||
| 	if err != nil { | ||||
| 		log.Error("SearchTeam failed: %v", err) | ||||
| 		ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ | ||||
| @@ -424,16 +474,16 @@ func EditTeamPost(ctx *context.Context) { | ||||
|  | ||||
| 	t.Description = form.Description | ||||
| 	if t.AccessMode < perm.AccessModeAdmin { | ||||
| 		units := make([]organization.TeamUnit, 0, len(unitPerms)) | ||||
| 		units := make([]org_model.TeamUnit, 0, len(unitPerms)) | ||||
| 		for tp, perm := range unitPerms { | ||||
| 			units = append(units, organization.TeamUnit{ | ||||
| 			units = append(units, org_model.TeamUnit{ | ||||
| 				OrgID:      t.OrgID, | ||||
| 				TeamID:     t.ID, | ||||
| 				Type:       tp, | ||||
| 				AccessMode: perm, | ||||
| 			}) | ||||
| 		} | ||||
| 		if err := organization.UpdateTeamUnits(t, units); err != nil { | ||||
| 		if err := org_model.UpdateTeamUnits(t, units); err != nil { | ||||
| 			ctx.Error(http.StatusInternalServerError, "UpdateTeamUnits", err.Error()) | ||||
| 			return | ||||
| 		} | ||||
| @@ -452,7 +502,7 @@ func EditTeamPost(ctx *context.Context) { | ||||
| 	if err := models.UpdateTeam(t, isAuthChanged, isIncludeAllChanged); err != nil { | ||||
| 		ctx.Data["Err_TeamName"] = true | ||||
| 		switch { | ||||
| 		case organization.IsErrTeamAlreadyExist(err): | ||||
| 		case org_model.IsErrTeamAlreadyExist(err): | ||||
| 			ctx.RenderWithErr(ctx.Tr("form.team_name_been_taken"), tplTeamNew, &form) | ||||
| 		default: | ||||
| 			ctx.ServerError("UpdateTeam", err) | ||||
| @@ -474,3 +524,72 @@ func DeleteTeam(ctx *context.Context) { | ||||
| 		"redirect": ctx.Org.OrgLink + "/teams", | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| // TeamInvite renders the team invite page | ||||
| func TeamInvite(ctx *context.Context) { | ||||
| 	invite, org, team, inviter, err := getTeamInviteFromContext(ctx) | ||||
| 	if err != nil { | ||||
| 		if org_model.IsErrTeamInviteNotFound(err) { | ||||
| 			ctx.NotFound("ErrTeamInviteNotFound", err) | ||||
| 		} else { | ||||
| 			ctx.ServerError("getTeamInviteFromContext", err) | ||||
| 		} | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	ctx.Data["Title"] = ctx.Tr("org.teams.invite_team_member", team.Name) | ||||
| 	ctx.Data["Invite"] = invite | ||||
| 	ctx.Data["Organization"] = org | ||||
| 	ctx.Data["Team"] = team | ||||
| 	ctx.Data["Inviter"] = inviter | ||||
|  | ||||
| 	ctx.HTML(http.StatusOK, tplTeamInvite) | ||||
| } | ||||
|  | ||||
| // TeamInvitePost handles the team invitation | ||||
| func TeamInvitePost(ctx *context.Context) { | ||||
| 	invite, org, team, _, err := getTeamInviteFromContext(ctx) | ||||
| 	if err != nil { | ||||
| 		if org_model.IsErrTeamInviteNotFound(err) { | ||||
| 			ctx.NotFound("ErrTeamInviteNotFound", err) | ||||
| 		} else { | ||||
| 			ctx.ServerError("getTeamInviteFromContext", err) | ||||
| 		} | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := models.AddTeamMember(team, ctx.Doer.ID); err != nil { | ||||
| 		ctx.ServerError("AddTeamMember", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := org_model.RemoveInviteByID(ctx, invite.ID, team.ID); err != nil { | ||||
| 		log.Error("RemoveInviteByID: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	ctx.Redirect(org.OrganisationLink() + "/teams/" + url.PathEscape(team.LowerName)) | ||||
| } | ||||
|  | ||||
| func getTeamInviteFromContext(ctx *context.Context) (*org_model.TeamInvite, *org_model.Organization, *org_model.Team, *user_model.User, error) { | ||||
| 	invite, err := org_model.GetInviteByToken(ctx, ctx.Params("token")) | ||||
| 	if err != nil { | ||||
| 		return nil, nil, nil, nil, err | ||||
| 	} | ||||
|  | ||||
| 	inviter, err := user_model.GetUserByIDCtx(ctx, invite.InviterID) | ||||
| 	if err != nil { | ||||
| 		return nil, nil, nil, nil, err | ||||
| 	} | ||||
|  | ||||
| 	team, err := org_model.GetTeamByID(ctx, invite.TeamID) | ||||
| 	if err != nil { | ||||
| 		return nil, nil, nil, nil, err | ||||
| 	} | ||||
|  | ||||
| 	org, err := user_model.GetUserByIDCtx(ctx, team.OrgID) | ||||
| 	if err != nil { | ||||
| 		return nil, nil, nil, nil, err | ||||
| 	} | ||||
|  | ||||
| 	return invite, org_model.OrgFromUser(org), team, inviter, nil | ||||
| } | ||||
|   | ||||
| @@ -651,6 +651,11 @@ func RegisterRoutes(m *web.Route) { | ||||
| 			m.Post("/create", bindIgnErr(forms.CreateOrgForm{}), org.CreatePost) | ||||
| 		}) | ||||
|  | ||||
| 		m.Group("/invite/{token}", func() { | ||||
| 			m.Get("", org.TeamInvite) | ||||
| 			m.Post("", org.TeamInvitePost) | ||||
| 		}) | ||||
|  | ||||
| 		m.Group("/{org}", func() { | ||||
| 			m.Get("/dashboard", user.Dashboard) | ||||
| 			m.Get("/dashboard/{team}", user.Dashboard) | ||||
|   | ||||
| @@ -23,7 +23,7 @@ const ( | ||||
| 	tplNewReleaseMail base.TplName = "release" | ||||
| ) | ||||
|  | ||||
| // MailNewRelease send new release notify to all all repo watchers. | ||||
| // MailNewRelease send new release notify to all repo watchers. | ||||
| func MailNewRelease(ctx context.Context, rel *repo_model.Release) { | ||||
| 	if setting.MailService == nil { | ||||
| 		// No mail service configured | ||||
|   | ||||
							
								
								
									
										62
									
								
								services/mailer/mail_team_invite.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								services/mailer/mail_team_invite.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| // Copyright 2022 The Gitea Authors. All rights reserved. | ||||
| // Use of this source code is governed by a MIT-style | ||||
| // license that can be found in the LICENSE file. | ||||
|  | ||||
| package mailer | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
|  | ||||
| 	org_model "code.gitea.io/gitea/models/organization" | ||||
| 	user_model "code.gitea.io/gitea/models/user" | ||||
| 	"code.gitea.io/gitea/modules/base" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/templates" | ||||
| 	"code.gitea.io/gitea/modules/translation" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	tplTeamInviteMail base.TplName = "team_invite" | ||||
| ) | ||||
|  | ||||
| // MailTeamInvite sends team invites | ||||
| func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_model.Team, invite *org_model.TeamInvite) error { | ||||
| 	if setting.MailService == nil { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	org, err := user_model.GetUserByIDCtx(ctx, team.OrgID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	locale := translation.NewLocale(inviter.Language) | ||||
|  | ||||
| 	subject := locale.Tr("mail.team_invite.subject", inviter.DisplayName(), org.DisplayName()) | ||||
| 	mailMeta := map[string]interface{}{ | ||||
| 		"Inviter":      inviter, | ||||
| 		"Organization": org, | ||||
| 		"Team":         team, | ||||
| 		"Invite":       invite, | ||||
| 		"Subject":      subject, | ||||
| 		// helper | ||||
| 		"locale":    locale, | ||||
| 		"Str2html":  templates.Str2html, | ||||
| 		"DotEscape": templates.DotEscape, | ||||
| 	} | ||||
|  | ||||
| 	var mailBody bytes.Buffer | ||||
| 	if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplTeamInviteMail), mailMeta); err != nil { | ||||
| 		log.Error("ExecuteTemplate [%s]: %v", string(tplTeamInviteMail)+"/body", err) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	msg := NewMessage([]string{invite.Email}, subject, mailBody.String()) | ||||
| 	msg.Info = subject | ||||
|  | ||||
| 	SendAsync(msg) | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										16
									
								
								templates/mail/team_invite.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								templates/mail/team_invite.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
| 	<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> | ||||
| 	<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no"/> | ||||
| </head> | ||||
| {{$invite_url := printf "%sorg/invite/%s" AppUrl (QueryEscape .Invite.Token)}} | ||||
| <body> | ||||
| 	<p>{{.locale.Tr "mail.team_invite.text_1" (DotEscape .Inviter.DisplayName) (DotEscape .Team.Name) (DotEscape .Organization.DisplayName) | Str2html}}</p> | ||||
| 	<p>{{.locale.Tr "mail.team_invite.text_2"}}</p><p><a href="{{$invite_url}}">{{$invite_url}}</a></p> | ||||
| 	<p>{{.locale.Tr "mail.link_not_working_do_paste"}}</p> | ||||
| 	<p>{{.locale.Tr "mail.team_invite.text_3" .Invite.Email}}</p> | ||||
|  | ||||
| 	<p>© <a target="_blank" rel="noopener noreferrer" href="{{AppUrl}}">{{AppName}}</a></p> | ||||
| </body> | ||||
| </html> | ||||
							
								
								
									
										23
									
								
								templates/org/team/invite.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								templates/org/team/invite.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| {{template "base/head" .}} | ||||
| <div class="page-content organization invite"> | ||||
| 	<div class="ui container"> | ||||
| 		{{template "base/alert" .}} | ||||
| 		<div class="ui centered card"> | ||||
| 			<div class="image"> | ||||
| 				{{avatar .Organization 140}} | ||||
| 			</div> | ||||
| 			<div class="content"> | ||||
| 				<div class="header">{{.locale.Tr "org.teams.invite.title" .Team.Name .Organization.Name | Str2html}}</div> | ||||
| 				<div class="meta">{{.locale.Tr "org.teams.invite.by" .Inviter.Name}}</div> | ||||
| 				<div class="description">{{.locale.Tr "org.teams.invite.description"}}</div> | ||||
| 			</div> | ||||
| 			<div class="extra content"> | ||||
| 				<form class="ui form" action="" method="post"> | ||||
| 					{{.CsrfTokenHtml}} | ||||
| 					<button class="fluid ui green button">{{.locale.Tr "org.teams.join"}}</button> | ||||
| 				</form> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </div> | ||||
| {{template "base/footer" .}} | ||||
| @@ -13,7 +13,7 @@ | ||||
| 							{{.CsrfTokenHtml}} | ||||
| 							<input type="hidden" name="uid" value="{{.SignedUser.ID}}"> | ||||
| 							<div class="inline field ui left"> | ||||
| 								<div id="search-user-box" class="ui search"> | ||||
| 								<div id="search-user-box" class="ui search"{{if .IsEmailInviteEnabled}} data-allow-email="true" data-allow-email-description="{{.locale.Tr "org.teams.invite_team_member" $.Team.Name}}"{{end}}> | ||||
| 									<div class="ui input"> | ||||
| 										<input class="prompt" name="uname" placeholder="{{.locale.Tr "repo.settings.search_user_placeholder"}}" autocomplete="off" required> | ||||
| 									</div> | ||||
| @@ -45,6 +45,21 @@ | ||||
| 						</div> | ||||
| 					{{end}} | ||||
| 				</div> | ||||
| 				{{if and .Invites $.IsOrganizationOwner}} | ||||
| 				<h4 class="ui top attached header">{{$.locale.Tr "org.teams.invite_team_member.list"}}</h4> | ||||
| 				<div class="ui bottom attached table segment members"> | ||||
| 					{{range .Invites}} | ||||
| 						<div class="item"> | ||||
| 							<form action="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/remove_invite" method="post"> | ||||
| 								{{$.CsrfTokenHtml}} | ||||
| 								<input type="hidden" name="iid" value="{{.ID}}" /> | ||||
| 								<button class="ui red button right">{{$.locale.Tr "org.members.remove"}}</button> | ||||
| 							</form> | ||||
| 							{{.Email}} | ||||
| 						</div> | ||||
| 					{{end}} | ||||
| 				</div> | ||||
| 				{{end}} | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
|   | ||||
							
								
								
									
										72
									
								
								tests/integration/org_team_invite_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								tests/integration/org_team_invite_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| // Copyright 2022 The Gitea Authors. All rights reserved. | ||||
| // Use of this source code is governed by a MIT-style | ||||
| // license that can be found in the LICENSE file. | ||||
|  | ||||
| package integration | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"testing" | ||||
|  | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| 	"code.gitea.io/gitea/models/organization" | ||||
| 	"code.gitea.io/gitea/models/unittest" | ||||
| 	user_model "code.gitea.io/gitea/models/user" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/test" | ||||
| 	"code.gitea.io/gitea/tests" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
|  | ||||
| func TestOrgTeamEmailInvite(t *testing.T) { | ||||
| 	if setting.MailService == nil { | ||||
| 		t.Skip() | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	defer tests.PrepareTestEnv(t)() | ||||
|  | ||||
| 	org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) | ||||
| 	team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}) | ||||
| 	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) | ||||
|  | ||||
| 	isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.False(t, isMember) | ||||
|  | ||||
| 	session := loginUser(t, "user1") | ||||
|  | ||||
| 	url := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name) | ||||
| 	csrf := GetCSRF(t, session, url) | ||||
| 	req := NewRequestWithValues(t, "POST", url+"/action/add", map[string]string{ | ||||
| 		"_csrf": csrf, | ||||
| 		"uid":   "1", | ||||
| 		"uname": user.Email, | ||||
| 	}) | ||||
| 	resp := session.MakeRequest(t, req, http.StatusSeeOther) | ||||
| 	req = NewRequest(t, "GET", test.RedirectURL(resp)) | ||||
| 	session.MakeRequest(t, req, http.StatusOK) | ||||
|  | ||||
| 	// get the invite token | ||||
| 	invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.Len(t, invites, 1) | ||||
|  | ||||
| 	session = loginUser(t, user.Name) | ||||
|  | ||||
| 	// join the team | ||||
| 	url = fmt.Sprintf("/org/invite/%s", invites[0].Token) | ||||
| 	csrf = GetCSRF(t, session, url) | ||||
| 	req = NewRequestWithValues(t, "POST", url, map[string]string{ | ||||
| 		"_csrf": csrf, | ||||
| 	}) | ||||
| 	resp = session.MakeRequest(t, req, http.StatusSeeOther) | ||||
| 	req = NewRequest(t, "GET", test.RedirectURL(resp)) | ||||
| 	session.MakeRequest(t, req, http.StatusOK) | ||||
|  | ||||
| 	isMember, err = organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.True(t, isMember) | ||||
| } | ||||
| @@ -3,15 +3,20 @@ import {htmlEscape} from 'escape-goat'; | ||||
|  | ||||
| const {appSubUrl} = window.config; | ||||
|  | ||||
| const looksLikeEmailAddressCheck = /^\S+@\S+$/; | ||||
|  | ||||
| export function initCompSearchUserBox() { | ||||
|   const $searchUserBox = $('#search-user-box'); | ||||
|   const allowEmailInput = $searchUserBox.attr('data-allow-email') === 'true'; | ||||
|   const allowEmailDescription = $searchUserBox.attr('data-allow-email-description'); | ||||
|   $searchUserBox.search({ | ||||
|     minCharacters: 2, | ||||
|     apiSettings: { | ||||
|       url: `${appSubUrl}/user/search?q={query}`, | ||||
|       onResponse(response) { | ||||
|         const items = []; | ||||
|         const searchQueryUppercase = $searchUserBox.find('input').val().toUpperCase(); | ||||
|         const searchQuery = $searchUserBox.find('input').val(); | ||||
|         const searchQueryUppercase = searchQuery.toUpperCase(); | ||||
|         $.each(response.data, (_i, item) => { | ||||
|           let title = item.login; | ||||
|           if (item.full_name && item.full_name.length > 0) { | ||||
| @@ -28,6 +33,14 @@ export function initCompSearchUserBox() { | ||||
|           } | ||||
|         }); | ||||
|  | ||||
|         if (allowEmailInput && items.length === 0 && looksLikeEmailAddressCheck.test(searchQuery)) { | ||||
|           const resultItem = { | ||||
|             title: searchQuery, | ||||
|             description: allowEmailDescription | ||||
|           }; | ||||
|           items.push(resultItem); | ||||
|         } | ||||
|  | ||||
|         return {results: items}; | ||||
|       } | ||||
|     }, | ||||
|   | ||||
| @@ -119,6 +119,11 @@ | ||||
|         margin-top: -3px; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .ui.avatar { | ||||
|       width: 100%; | ||||
|       height: 100%; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.members { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user