From c3b2e44392e7f6c9a77a46664788c0bb9a6f33cb Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Wed, 19 Oct 2022 14:40:28 +0200 Subject: [PATCH] Add team member invite by email (#20307) Allows to add (not registered) team members by email. related #5353 Invite by mail: ![grafik](https://user-images.githubusercontent.com/1666336/178154779-adcc547f-c0b7-4a2a-a131-4e41a3d9d3ad.png) Pending invitations: ![grafik](https://user-images.githubusercontent.com/1666336/178154882-9d739bb8-2b04-46c1-a025-c1f4be26af98.png) Email: ![grafik](https://user-images.githubusercontent.com/1666336/178164716-f2f90893-7ba6-4a5e-a3db-42538a660258.png) Join form: ![grafik](https://user-images.githubusercontent.com/1666336/178154840-aaab983a-d922-4414-b01a-9b1a19c5cef7.png) Co-authored-by: Jack Hay --- models/migrations/migrations.go | 2 + models/migrations/v228.go | 26 ++++ models/org_team.go | 22 +-- models/organization/org.go | 3 +- models/organization/team.go | 1 + models/organization/team_invite.go | 162 +++++++++++++++++++++ models/organization/team_invite_test.go | 49 +++++++ options/locale/locale_en-US.ini | 11 ++ routers/web/org/teams.go | 165 +++++++++++++++++++--- routers/web/web.go | 5 + services/mailer/mail_release.go | 2 +- services/mailer/mail_team_invite.go | 62 ++++++++ templates/mail/team_invite.tmpl | 16 +++ templates/org/team/invite.tmpl | 23 +++ templates/org/team/members.tmpl | 17 ++- tests/integration/org_team_invite_test.go | 72 ++++++++++ web_src/js/features/comp/SearchUserBox.js | 15 +- web_src/less/_organization.less | 5 + 18 files changed, 615 insertions(+), 43 deletions(-) create mode 100644 models/migrations/v228.go create mode 100644 models/organization/team_invite.go create mode 100644 models/organization/team_invite_test.go create mode 100644 services/mailer/mail_team_invite.go create mode 100644 templates/mail/team_invite.tmpl create mode 100644 templates/org/team/invite.tmpl create mode 100644 tests/integration/org_team_invite_test.go diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index afe1445a23..46ef052829 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -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 diff --git a/models/migrations/v228.go b/models/migrations/v228.go new file mode 100644 index 0000000000..62c81ef9d8 --- /dev/null +++ b/models/migrations/v228.go @@ -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)) +} diff --git a/models/org_team.go b/models/org_team.go index 61ddd2a047..6066e7f5c9 100644 --- a/models/org_team.go +++ b/models/org_team.go @@ -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 diff --git a/models/organization/org.go b/models/organization/org.go index fbbf6d04fa..58b58e6732 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -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 { diff --git a/models/organization/team.go b/models/organization/team.go index 83e5bd6fe1..aa9b24b57f 100644 --- a/models/organization/team.go +++ b/models/organization/team.go @@ -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 diff --git a/models/organization/team_invite.go b/models/organization/team_invite.go new file mode 100644 index 0000000000..4504a2e9fe --- /dev/null +++ b/models/organization/team_invite.go @@ -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 +} diff --git a/models/organization/team_invite_test.go b/models/organization/team_invite_test.go new file mode 100644 index 0000000000..e0596ec28d --- /dev/null +++ b/models/organization/team_invite_test.go @@ -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) + }) +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index e5da074f64..a35c6a668a 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -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 Read access to all repositories: members can view and clone repositories. teams.all_repositories_write_permission_desc = This team grants Write access to all repositories: members can read from and push to repositories. teams.all_repositories_admin_permission_desc = This team grants Admin access to all repositories: members can read from, push to and add collaborators to repositories. +teams.invite.title = You've been invited to join team %s in organization %s. +teams.invite.by = Invited by %s +teams.invite.description = Please click the button below to join the team. [admin] dashboard = Dashboard diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index 13c88565c4..bcdbcbe079 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -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 +} diff --git a/routers/web/web.go b/routers/web/web.go index 8859ec5850..62503b3141 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go index 7c44f93929..6df3fbbf1d 100644 --- a/services/mailer/mail_release.go +++ b/services/mailer/mail_release.go @@ -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 diff --git a/services/mailer/mail_team_invite.go b/services/mailer/mail_team_invite.go new file mode 100644 index 0000000000..c2b2a00e76 --- /dev/null +++ b/services/mailer/mail_team_invite.go @@ -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 +} diff --git a/templates/mail/team_invite.tmpl b/templates/mail/team_invite.tmpl new file mode 100644 index 0000000000..163c950e94 --- /dev/null +++ b/templates/mail/team_invite.tmpl @@ -0,0 +1,16 @@ + + + + + + +{{$invite_url := printf "%sorg/invite/%s" AppUrl (QueryEscape .Invite.Token)}} + +

{{.locale.Tr "mail.team_invite.text_1" (DotEscape .Inviter.DisplayName) (DotEscape .Team.Name) (DotEscape .Organization.DisplayName) | Str2html}}

+

{{.locale.Tr "mail.team_invite.text_2"}}

{{$invite_url}}

+

{{.locale.Tr "mail.link_not_working_do_paste"}}

+

{{.locale.Tr "mail.team_invite.text_3" .Invite.Email}}

+ +

© {{AppName}}

+ + diff --git a/templates/org/team/invite.tmpl b/templates/org/team/invite.tmpl new file mode 100644 index 0000000000..a696d99498 --- /dev/null +++ b/templates/org/team/invite.tmpl @@ -0,0 +1,23 @@ +{{template "base/head" .}} +
+
+ {{template "base/alert" .}} +
+
+ {{avatar .Organization 140}} +
+
+
{{.locale.Tr "org.teams.invite.title" .Team.Name .Organization.Name | Str2html}}
+
{{.locale.Tr "org.teams.invite.by" .Inviter.Name}}
+
{{.locale.Tr "org.teams.invite.description"}}
+
+
+
+ {{.CsrfTokenHtml}} + +
+
+
+
+
+{{template "base/footer" .}} diff --git a/templates/org/team/members.tmpl b/templates/org/team/members.tmpl index ecb7830f18..1a58dc5339 100644 --- a/templates/org/team/members.tmpl +++ b/templates/org/team/members.tmpl @@ -13,7 +13,7 @@ {{.CsrfTokenHtml}}
- + {{if and .Invites $.IsOrganizationOwner}} +

{{$.locale.Tr "org.teams.invite_team_member.list"}}

+
+ {{range .Invites}} +
+
+ {{$.CsrfTokenHtml}} + + +
+ {{.Email}} +
+ {{end}} +
+ {{end}}
diff --git a/tests/integration/org_team_invite_test.go b/tests/integration/org_team_invite_test.go new file mode 100644 index 0000000000..470478589a --- /dev/null +++ b/tests/integration/org_team_invite_test.go @@ -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) +} diff --git a/web_src/js/features/comp/SearchUserBox.js b/web_src/js/features/comp/SearchUserBox.js index 08f97595af..46ecb8ebf4 100644 --- a/web_src/js/features/comp/SearchUserBox.js +++ b/web_src/js/features/comp/SearchUserBox.js @@ -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}; } }, diff --git a/web_src/less/_organization.less b/web_src/less/_organization.less index b80739671f..c52753e29b 100644 --- a/web_src/less/_organization.less +++ b/web_src/less/_organization.less @@ -119,6 +119,11 @@ margin-top: -3px; } } + + .ui.avatar { + width: 100%; + height: 100%; + } } &.members {