From 31bd5f3fa6325e4968cc15dc8d3204cb3e889d2d Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Sun, 10 Jul 2022 17:04:25 +0000 Subject: [PATCH 01/12] Added team invite by email. --- models/migrations/migrations.go | 2 + models/migrations/v219.go | 25 ++++ models/org_team.go | 22 +-- models/organization/org.go | 12 +- models/organization/team.go | 1 + models/organization/team_invite.go | 111 +++++++++++++++ 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 ++- web_src/js/features/comp/SearchUserBox.js | 15 +- web_src/less/_organization.less | 5 + 16 files changed, 451 insertions(+), 43 deletions(-) create mode 100644 models/migrations/v219.go create mode 100644 models/organization/team_invite.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 diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 0d35ac78d3ece..4425b968ea31e 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -396,6 +396,8 @@ var migrations = []Migration{ NewMigration("Alter hook_task table TEXT fields to LONGTEXT", alterHookTaskTextFieldsToLongText), // v218 -> v219 NewMigration("Improve Action table indices v2", improveActionTableIndices), + // v219 -> v220 + NewMigration("Add TeamInvite table", addTeamInviteTable), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v219.go b/models/migrations/v219.go new file mode 100644 index 0000000000000..fa4abcc987c8c --- /dev/null +++ b/models/migrations/v219.go @@ -0,0 +1,25 @@ +// 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"` + InviterID int64 `xorm:"NOT NULL"` + TeamID int64 `xorm:"UNIQUE(team_mail) INDEX NOT NULL"` + Email string `xorm:"UNIQUE(team_mail) NOT NULL"` + 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 5d29e333373ad..527d2b31d772d 100644 --- a/models/org_team.go +++ b/models/org_team.go @@ -453,25 +453,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 { - return err - } - - // Delete team-unit. - if _, err := sess. - Where("team_id=?", t.ID). - Delete(new(organization.TeamUnit)); 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. - 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 044ea065637c5..451e7c32865cf 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -356,13 +356,23 @@ func DeleteOrganization(ctx context.Context, org *Organization) error { return fmt.Errorf("%s is a user not an organization", org.Name) } + teams, err := FindOrgTeams(ctx, org.ID) + if err != nil { + return fmt.Errorf("FindOrgTeams: %v", err) + } + for _, team := range teams { + if err := db.DeleteBeans(ctx, &TeamInvite{TeamID: team.ID}); err != nil { + return fmt.Errorf("DeleteBeans: %v", err) + } + } + if err := db.DeleteBeans(ctx, &Team{OrgID: org.ID}, &OrgUser{OrgID: org.ID}, &TeamUser{OrgID: org.ID}, &TeamUnit{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 0b53c84d67036..184d192773b2f 100644 --- a/models/organization/team.go +++ b/models/organization/team.go @@ -85,6 +85,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 0000000000000..31a16ed0b8989 --- /dev/null +++ b/models/organization/team_invite.go @@ -0,0 +1,111 @@ +// 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" +) + +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) +} + +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) +} + +// TeamInvite represents an invite to a team +type TeamInvite struct { + ID int64 `xorm:"pk autoincr"` + Token string `xorm:"UNIQUE(token) INDEX"` + InviterID int64 `xorm:"NOT NULL"` + TeamID int64 `xorm:"UNIQUE(team_mail) INDEX NOT NULL"` + Email string `xorm:"UNIQUE(team_mail) NOT NULL"` + 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, + } + } + + token, err := util.CryptoRandomString(25) + if err != nil { + return nil, err + } + + invite := &TeamInvite{ + Token: token, + InviterID: doer.ID, + 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/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index eb7ae4774313b..12a2c8221bd23 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -406,6 +406,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 the %[2]s team of the %[3]s organization. +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 @@ -481,6 +486,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 @@ -2383,6 +2389,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 = Team 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. @@ -2407,6 +2415,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 the %s team of the %s organization. +teams.invite.by = Invited by %s +teams.invite.description = Please click the following button to join the team. [admin] dashboard = Dashboard diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index 284fb096f3405..6f86861e00664 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" ) const ( @@ -37,6 +39,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 @@ -58,12 +62,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") { @@ -76,7 +74,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) @@ -97,9 +95,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) @@ -125,10 +130,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 { + 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 } @@ -145,11 +167,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) @@ -223,7 +264,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) } @@ -254,7 +295,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, @@ -264,9 +305,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, @@ -294,7 +335,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) @@ -315,6 +356,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) } @@ -338,7 +388,7 @@ func SearchTeam(ctx *context.Context) { PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), } - opts := &organization.SearchTeamOptions{ + opts := &org_model.SearchTeamOptions{ UserID: ctx.Doer.ID, Keyword: ctx.FormTrim("q"), OrgID: ctx.Org.Organization.ID, @@ -346,7 +396,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{}{ @@ -419,16 +469,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 } @@ -448,7 +498,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) @@ -470,3 +520,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 1b6dd03bc8a84..81787ce5b6fd5 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -616,6 +616,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 dd9f78612c445..6e57d29703992 100644 --- a/services/mailer/mail_release.go +++ b/services/mailer/mail_release.go @@ -24,7 +24,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 *models.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 0000000000000..c2b2a00e76097 --- /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 0000000000000..163c950e94178 --- /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 0000000000000..a696d99498007 --- /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 b9069c90de677..d31bba6ed03ee 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/web_src/js/features/comp/SearchUserBox.js b/web_src/js/features/comp/SearchUserBox.js index 08f97595af0c6..8e80462f99267 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 emailAddressCheck = /\S+@\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 && emailAddressCheck.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 b80739671f28e..c52753e29b0f3 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 { From bc4ea055db37e65ecf91eae91f5550064873551c Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Sun, 10 Jul 2022 17:13:02 +0000 Subject: [PATCH 02/12] Changed text. --- options/locale/locale_en-US.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 787392ce37096..be83e30569590 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2392,7 +2392,7 @@ 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 = Team Invitations +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. From 7cce63c8885a76bbbec5dc822f0c0979a18156c8 Mon Sep 17 00:00:00 2001 From: Jack Hay Date: Tue, 12 Jul 2022 10:38:46 -0400 Subject: [PATCH 03/12] check if user added to team by email (#2) * add check to see if the invite email corresponds to a user already added to the team * add unit test * add unit tests for duplicate creation, removal * add integration test for invite token, consolidate db query * formatting fix * formatting fixes --- integrations/invite_test.go | 67 +++++++++++++++++++++++++ models/organization/team_invite.go | 37 ++++++++++++++ models/organization/team_invite_test.go | 58 +++++++++++++++++++++ routers/web/org/teams.go | 2 + 4 files changed, 164 insertions(+) create mode 100644 integrations/invite_test.go create mode 100644 models/organization/team_invite_test.go diff --git a/integrations/invite_test.go b/integrations/invite_test.go new file mode 100644 index 0000000000000..882cab1465d03 --- /dev/null +++ b/integrations/invite_test.go @@ -0,0 +1,67 @@ +// 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 integrations + +import ( + "fmt" + "net/http" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestEmailInvite(t *testing.T) { + defer prepareTestEnv(t)() + session := loginUser(t, "user1") + + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}).(*organization.Organization) + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}).(*organization.Team) + + req := NewRequestf(t, "GET", "/org/%s/teams/%s", org.Name, team.Name) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + url := fmt.Sprintf("/org/%s/teams/%s/action/add", org.Name, team.Name) + + req = NewRequestWithValues(t, "POST", url, map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "uid": "1", + "uname": "user3@example.com", + }) + + resp = session.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequest(t, "GET", test.RedirectURL(resp)) + 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) + + user3Session := loginUser(t, "user3") + + // load the join page + req = NewRequestf(t, "GET", "/org/invite/%s", invites[0].Token) + resp = user3Session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + + url = fmt.Sprintf("/org/invite/%s", invites[0].Token) + + // join the team + req = NewRequestWithValues(t, "POST", url, map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + }) + + resp = session.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequest(t, "GET", test.RedirectURL(resp)) + resp = session.MakeRequest(t, req, http.StatusOK) +} diff --git a/models/organization/team_invite.go b/models/organization/team_invite.go index 31a16ed0b8989..2da5112d187e7 100644 --- a/models/organization/team_invite.go +++ b/models/organization/team_invite.go @@ -12,6 +12,8 @@ import ( 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 { @@ -41,6 +43,21 @@ func (err ErrTeamInviteNotFound) Error() string { return fmt.Sprintf("team invite was not found [token: %s]", err.Token) } +// 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) +} + // TeamInvite represents an invite to a team type TeamInvite struct { ID int64 `xorm:"pk autoincr"` @@ -67,6 +84,26 @@ func CreateTeamInvite(ctx context.Context, doer *user_model.User, team *Team, em } } + // 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 diff --git a/models/organization/team_invite_test.go b/models/organization/team_invite_test.go new file mode 100644 index 0000000000000..1e7f2c0c99ae6 --- /dev/null +++ b/models/organization/team_invite_test.go @@ -0,0 +1,58 @@ +// 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 TestTeam_EmailExists(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}).(*organization.Team) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User) + + // user 2 already added to team 2, should result in error + _, err := organization.CreateTeamInvite(db.DefaultContext, user2, team, "user2@example.com") + assert.Error(t, err) +} + +func TestTeam_Invite(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}).(*organization.Team) + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) + + _, err := organization.CreateTeamInvite(db.DefaultContext, user1, team, "user3@example.com") + assert.NoError(t, err) + + // Shouldn't allow duplicate invite + _, err = organization.CreateTeamInvite(db.DefaultContext, user1, team, "user3@example.com") + assert.Error(t, err) +} + +func TestTeam_RemoveInvite(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}).(*organization.Team) + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) + + invite, err := organization.CreateTeamInvite(db.DefaultContext, user1, team, "user5@example.com") + assert.NoError(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/routers/web/org/teams.go b/routers/web/org/teams.go index 6f86861e00664..778ad9e5a9d33 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -135,6 +135,8 @@ func TeamsAction(ctx *context.Context) { 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 From b9c487944a34a90081dfad96f8a744e27ae64a47 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Mon, 8 Aug 2022 19:34:19 +0000 Subject: [PATCH 04/12] Changed file name. --- integrations/{invite_test.go => org_team_invite_test.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename integrations/{invite_test.go => org_team_invite_test.go} (100%) diff --git a/integrations/invite_test.go b/integrations/org_team_invite_test.go similarity index 100% rename from integrations/invite_test.go rename to integrations/org_team_invite_test.go From 420dd67e23df5269d06481f6ec40c58733ebdcb9 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Mon, 8 Aug 2022 20:11:43 +0000 Subject: [PATCH 05/12] Updated tests. --- integrations/org_team_invite_test.go | 36 ++++++----------- models/organization/team_invite_test.go | 51 ++++++++++--------------- 2 files changed, 33 insertions(+), 54 deletions(-) diff --git a/integrations/org_team_invite_test.go b/integrations/org_team_invite_test.go index 882cab1465d03..5e2a55f32ed11 100644 --- a/integrations/org_team_invite_test.go +++ b/integrations/org_team_invite_test.go @@ -17,27 +17,22 @@ import ( "github.com/stretchr/testify/assert" ) -func TestEmailInvite(t *testing.T) { +func TestOrgTeamEmailInvite(t *testing.T) { defer prepareTestEnv(t)() - session := loginUser(t, "user1") org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}).(*organization.Organization) team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}).(*organization.Team) - req := NewRequestf(t, "GET", "/org/%s/teams/%s", org.Name, team.Name) - resp := session.MakeRequest(t, req, http.StatusOK) - htmlDoc := NewHTMLParser(t, resp.Body) - - url := fmt.Sprintf("/org/%s/teams/%s/action/add", org.Name, team.Name) + session := loginUser(t, "user1") - req = NewRequestWithValues(t, "POST", url, map[string]string{ - "_csrf": htmlDoc.GetCSRF(), + 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": "user3@example.com", + "uname": "user5@example.com", }) - - resp = session.MakeRequest(t, req, http.StatusSeeOther) - + resp := session.MakeRequest(t, req, http.StatusSeeOther) req = NewRequest(t, "GET", test.RedirectURL(resp)) resp = session.MakeRequest(t, req, http.StatusOK) @@ -46,22 +41,15 @@ func TestEmailInvite(t *testing.T) { assert.NoError(t, err) assert.Len(t, invites, 1) - user3Session := loginUser(t, "user3") - - // load the join page - req = NewRequestf(t, "GET", "/org/invite/%s", invites[0].Token) - resp = user3Session.MakeRequest(t, req, http.StatusOK) - htmlDoc = NewHTMLParser(t, resp.Body) - - url = fmt.Sprintf("/org/invite/%s", invites[0].Token) + session = loginUser(t, "user5") // 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": htmlDoc.GetCSRF(), + "_csrf": csrf, }) - resp = session.MakeRequest(t, req, http.StatusSeeOther) - req = NewRequest(t, "GET", test.RedirectURL(resp)) resp = session.MakeRequest(t, req, http.StatusOK) } diff --git a/models/organization/team_invite_test.go b/models/organization/team_invite_test.go index 1e7f2c0c99ae6..80a83cd35bca3 100644 --- a/models/organization/team_invite_test.go +++ b/models/organization/team_invite_test.go @@ -15,44 +15,35 @@ import ( "github.com/stretchr/testify/assert" ) -func TestTeam_EmailExists(t *testing.T) { +func TestTeamInvite(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}).(*organization.Team) - user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User) - // user 2 already added to team 2, should result in error - _, err := organization.CreateTeamInvite(db.DefaultContext, user2, team, "user2@example.com") - assert.Error(t, err) -} + t.Run("MailExistsInTeam", func(t *testing.T) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User) -func TestTeam_Invite(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) + // user 2 already added to team 2, should result in error + _, err := organization.CreateTeamInvite(db.DefaultContext, user2, team, user2.Email) + assert.Error(t, err) + }) - team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}).(*organization.Team) - user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) + t.Run("CreateAndRemove", func(t *testing.T) { + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) - _, err := organization.CreateTeamInvite(db.DefaultContext, user1, team, "user3@example.com") - assert.NoError(t, err) - - // Shouldn't allow duplicate invite - _, err = organization.CreateTeamInvite(db.DefaultContext, user1, team, "user3@example.com") - assert.Error(t, err) -} - -func TestTeam_RemoveInvite(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - - team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}).(*organization.Team) - user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) + invite, err := organization.CreateTeamInvite(db.DefaultContext, user1, team, "user3@example.com") + assert.NotNil(t, invite) + assert.NoError(t, err) - invite, err := organization.CreateTeamInvite(db.DefaultContext, user1, team, "user5@example.com") - 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)) + // 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) + // invite should not exist + _, err = organization.GetInviteByToken(db.DefaultContext, invite.Token) + assert.Error(t, err) + }) } From 981639fa181048abae5e28ca563b6758c3d2168d Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Mon, 8 Aug 2022 20:13:19 +0000 Subject: [PATCH 06/12] lint --- integrations/org_team_invite_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/integrations/org_team_invite_test.go b/integrations/org_team_invite_test.go index 5e2a55f32ed11..1e74f388a186b 100644 --- a/integrations/org_team_invite_test.go +++ b/integrations/org_team_invite_test.go @@ -27,14 +27,14 @@ func TestOrgTeamEmailInvite(t *testing.T) { 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{ + req := NewRequestWithValues(t, "POST", url+"/action/add", map[string]string{ "_csrf": csrf, "uid": "1", "uname": "user5@example.com", }) resp := session.MakeRequest(t, req, http.StatusSeeOther) req = NewRequest(t, "GET", test.RedirectURL(resp)) - resp = session.MakeRequest(t, req, http.StatusOK) + session.MakeRequest(t, req, http.StatusOK) // get the invite token invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID) @@ -51,5 +51,5 @@ func TestOrgTeamEmailInvite(t *testing.T) { }) resp = session.MakeRequest(t, req, http.StatusSeeOther) req = NewRequest(t, "GET", test.RedirectURL(resp)) - resp = session.MakeRequest(t, req, http.StatusOK) + session.MakeRequest(t, req, http.StatusOK) } From c8d47d14b20fa7feaef8c9e7b462e5c69eb349d7 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Mon, 8 Aug 2022 21:25:11 +0000 Subject: [PATCH 07/12] Escape user. --- models/organization/team_invite.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/models/organization/team_invite.go b/models/organization/team_invite.go index 2da5112d187e7..b5c43d8ec4616 100644 --- a/models/organization/team_invite.go +++ b/models/organization/team_invite.go @@ -89,9 +89,9 @@ func CreateTeamInvite(ctx context.Context, doer *user_model.User, team *Team, em Where(builder.Eq{ "team_user.org_id": team.OrgID, "team_user.team_id": team.ID, - "user.email": email, + "`user`.email": email, }). - Join("INNER", "user", "user.id = team_user.uid"). + Join("INNER", "`user`", "`user`.id = team_user.uid"). Table("team_user"). Exist() if err != nil { From 5b2cf17983f86b327f6cb008c4c7ef37bca9f1a3 Mon Sep 17 00:00:00 2001 From: 6543 <6543@obermui.de> Date: Wed, 10 Aug 2022 01:42:42 +0200 Subject: [PATCH 08/12] make fmt --- models/organization/team_invite.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/organization/team_invite.go b/models/organization/team_invite.go index b5c43d8ec4616..dd52fc1540e0d 100644 --- a/models/organization/team_invite.go +++ b/models/organization/team_invite.go @@ -89,7 +89,7 @@ func CreateTeamInvite(ctx context.Context, doer *user_model.User, team *Team, em Where(builder.Eq{ "team_user.org_id": team.OrgID, "team_user.team_id": team.ID, - "`user`.email": email, + "`user`.email": email, }). Join("INNER", "`user`", "`user`.id = team_user.uid"). Table("team_user"). From 83badccfde05dd3734d1ed03dcc2ed11d7a7c2c4 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Wed, 10 Aug 2022 19:06:56 +0000 Subject: [PATCH 09/12] Skip test if MailService is not available. --- integrations/org_team_invite_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/integrations/org_team_invite_test.go b/integrations/org_team_invite_test.go index 1e74f388a186b..ac8c8da939195 100644 --- a/integrations/org_team_invite_test.go +++ b/integrations/org_team_invite_test.go @@ -12,12 +12,18 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/test" "github.com/stretchr/testify/assert" ) func TestOrgTeamEmailInvite(t *testing.T) { + if setting.MailService == nil { + t.Skip() + return + } + defer prepareTestEnv(t)() org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}).(*organization.Organization) From 5fc26b79888d61628cde644115bd66facf6f3888 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Sun, 11 Sep 2022 12:37:18 +0000 Subject: [PATCH 10/12] Add suggestions. --- models/migrations/v226.go | 1 + models/organization/org.go | 11 +---------- models/organization/team_invite.go | 2 ++ options/locale/locale_en-US.ini | 6 +++--- routers/web/org/teams.go | 8 +++----- templates/org/team/members.tmpl | 2 +- 6 files changed, 11 insertions(+), 19 deletions(-) diff --git a/models/migrations/v226.go b/models/migrations/v226.go index fa4abcc987c8c..c81d875cc6142 100644 --- a/models/migrations/v226.go +++ b/models/migrations/v226.go @@ -15,6 +15,7 @@ func addTeamInviteTable(x *xorm.Engine) error { ID int64 `xorm:"pk autoincr"` Token string `xorm:"UNIQUE(token) INDEX"` InviterID int64 `xorm:"NOT NULL"` + OrgID int64 `xorm:"INDEX"` TeamID int64 `xorm:"UNIQUE(team_mail) INDEX NOT NULL"` Email string `xorm:"UNIQUE(team_mail) NOT NULL"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` diff --git a/models/organization/org.go b/models/organization/org.go index 451e7c32865cf..5ca0e40bb5d3f 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -356,21 +356,12 @@ func DeleteOrganization(ctx context.Context, org *Organization) error { return fmt.Errorf("%s is a user not an organization", org.Name) } - teams, err := FindOrgTeams(ctx, org.ID) - if err != nil { - return fmt.Errorf("FindOrgTeams: %v", err) - } - for _, team := range teams { - if err := db.DeleteBeans(ctx, &TeamInvite{TeamID: team.ID}); err != nil { - return fmt.Errorf("DeleteBeans: %v", err) - } - } - if err := db.DeleteBeans(ctx, &Team{OrgID: org.ID}, &OrgUser{OrgID: org.ID}, &TeamUser{OrgID: org.ID}, &TeamUnit{OrgID: org.ID}, + &TeamInvite{OrgID: org.ID}, ); err != nil { return fmt.Errorf("DeleteBeans: %v", err) } diff --git a/models/organization/team_invite.go b/models/organization/team_invite.go index dd52fc1540e0d..51a88f07c7927 100644 --- a/models/organization/team_invite.go +++ b/models/organization/team_invite.go @@ -63,6 +63,7 @@ type TeamInvite struct { ID int64 `xorm:"pk autoincr"` Token string `xorm:"UNIQUE(token) INDEX"` InviterID int64 `xorm:"NOT NULL"` + OrgID int64 `xorm:"INDEX"` TeamID int64 `xorm:"UNIQUE(team_mail) INDEX NOT NULL"` Email string `xorm:"UNIQUE(team_mail) NOT NULL"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` @@ -112,6 +113,7 @@ func CreateTeamInvite(ctx context.Context, doer *user_model.User, team *Team, em invite := &TeamInvite{ Token: token, InviterID: doer.ID, + OrgID: team.OrgID, TeamID: team.ID, Email: email, } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 3b457fb46961a..00be040b81f50 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -410,7 +410,7 @@ 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 the %[2]s team of the %[3]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. @@ -2425,9 +2425,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 the %s team of the %s organization. +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 following button to join the team. +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 c9f751b9da88c..bcdbcbe079394 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -142,11 +142,9 @@ func TeamsAction(ctx *context.Context) { 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 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")) diff --git a/templates/org/team/members.tmpl b/templates/org/team/members.tmpl index d31bba6ed03ee..c844c1c3c4297 100644 --- a/templates/org/team/members.tmpl +++ b/templates/org/team/members.tmpl @@ -13,7 +13,7 @@ {{.CsrfTokenHtml}}
-