summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKN4CK3R <admin@oldschoolhack.me>2022-10-19 14:40:28 +0200
committerGitHub <noreply@github.com>2022-10-19 14:40:28 +0200
commitc3b2e44392e7f6c9a77a46664788c0bb9a6f33cb (patch)
tree6fe65807cd30770951ede4396a149ce03fb56b96
parent7d1aed83f4d0cdf096ec8758ff8a85ddccf1328d (diff)
downloadgitea-c3b2e44392e7f6c9a77a46664788c0bb9a6f33cb.tar.gz
gitea-c3b2e44392e7f6c9a77a46664788c0bb9a6f33cb.zip
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 <jjphay@gmail.com>
-rw-r--r--models/migrations/migrations.go2
-rw-r--r--models/migrations/v228.go26
-rw-r--r--models/org_team.go22
-rw-r--r--models/organization/org.go3
-rw-r--r--models/organization/team.go1
-rw-r--r--models/organization/team_invite.go162
-rw-r--r--models/organization/team_invite_test.go49
-rw-r--r--options/locale/locale_en-US.ini11
-rw-r--r--routers/web/org/teams.go165
-rw-r--r--routers/web/web.go5
-rw-r--r--services/mailer/mail_release.go2
-rw-r--r--services/mailer/mail_team_invite.go62
-rw-r--r--templates/mail/team_invite.tmpl16
-rw-r--r--templates/org/team/invite.tmpl23
-rw-r--r--templates/org/team/members.tmpl17
-rw-r--r--tests/integration/org_team_invite_test.go72
-rw-r--r--web_src/js/features/comp/SearchUserBox.js15
-rw-r--r--web_src/less/_organization.less5
18 files changed, 615 insertions, 43 deletions
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 {
- 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 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 <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
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
}
@@ -147,10 +169,29 @@ func TeamsAction(ctx *context.Context) {
}
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 @@
+<!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>
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" .}}
+<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" .}}
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}}
<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>
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 {