Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add team member invite by email #20307

Merged
merged 37 commits into from
Oct 19, 2022
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
31bd5f3
Added team invite by email.
KN4CK3R Jul 10, 2022
6c84ab0
Merge branch 'main' of https://github.com/go-gitea/gitea into feature…
KN4CK3R Jul 10, 2022
bc4ea05
Changed text.
KN4CK3R Jul 10, 2022
7cce63c
check if user added to team by email (#2)
jackHay22 Jul 12, 2022
9ba8d28
Merge branch 'main' of https://github.com/go-gitea/gitea into feature…
KN4CK3R Aug 7, 2022
bb2aba5
Merge branch 'feature-invite' of https://github.com/KN4CK3R/gitea int…
KN4CK3R Aug 7, 2022
b7128f4
Merge branch 'main' of https://github.com/go-gitea/gitea into feature…
KN4CK3R Aug 8, 2022
b9c4879
Changed file name.
KN4CK3R Aug 8, 2022
420dd67
Updated tests.
KN4CK3R Aug 8, 2022
981639f
lint
KN4CK3R Aug 8, 2022
8bd324e
Merge branch 'main' into feature-invite
6543 Aug 8, 2022
c8d47d1
Escape user.
KN4CK3R Aug 8, 2022
f3eaf01
Merge branch 'feature-invite' of https://github.com/KN4CK3R/gitea int…
KN4CK3R Aug 8, 2022
5b2cf17
make fmt
6543 Aug 9, 2022
48a709a
Merge branch 'main' into feature-invite
6543 Aug 9, 2022
83badcc
Skip test if MailService is not available.
KN4CK3R Aug 10, 2022
4eb81d5
Merge branch 'main' into feature-invite
6543 Aug 14, 2022
7b21c06
Merge branch 'main' of https://github.com/go-gitea/gitea into feature…
KN4CK3R Aug 23, 2022
6874979
Merge branch 'main' of https://github.com/go-gitea/gitea into feature…
KN4CK3R Aug 26, 2022
eee0639
Merge branch 'main' of https://github.com/go-gitea/gitea into feature…
KN4CK3R Aug 27, 2022
bdf4f6c
Merge branch 'main' of https://github.com/go-gitea/gitea into feature…
KN4CK3R Sep 7, 2022
5fc26b7
Add suggestions.
KN4CK3R Sep 11, 2022
de9c196
Update regex.
KN4CK3R Sep 11, 2022
9035122
Merge branch 'main' of https://github.com/go-gitea/gitea into feature…
KN4CK3R Sep 12, 2022
960982b
Merge branch 'main' into feature-invite
KN4CK3R Sep 13, 2022
7b13b4a
Merge branch 'main' of https://github.com/go-gitea/gitea into feature…
KN4CK3R Sep 24, 2022
8de58b3
Merge branch 'main' into feature-invite
6543 Sep 26, 2022
e9013dc
Merge branch 'main' of https://github.com/go-gitea/gitea into feature…
KN4CK3R Oct 7, 2022
2a1e008
Merge branch 'main' into feature-invite
6543 Oct 7, 2022
ffae78c
Merge branch 'main' into feature-invite
6543 Oct 8, 2022
e3472c0
Merge branch 'main' into feature-invite
KN4CK3R Oct 10, 2022
f6d8c82
Merge branch 'main' into feature-invite
6543 Oct 10, 2022
62eb11b
Merge branch 'main' into feature-invite
6543 Oct 10, 2022
7794085
Merge branch 'main' into feature-invite
6543 Oct 16, 2022
5a4fbe5
Merge branch 'main' of https://github.com/go-gitea/gitea into feature…
KN4CK3R Oct 16, 2022
f1875c8
Check membership in test.
KN4CK3R Oct 16, 2022
073cec2
Merge branch 'main' of https://github.com/go-gitea/gitea into feature…
KN4CK3R Oct 19, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions integrations/org_team_invite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// 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
KN4CK3R marked this conversation as resolved.
Show resolved Hide resolved

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/setting"
"code.gitea.io/gitea/modules/test"
KN4CK3R marked this conversation as resolved.
Show resolved Hide resolved

"github.com/stretchr/testify/assert"
)

func TestOrgTeamEmailInvite(t *testing.T) {
if setting.MailService == nil {
t.Skip()
return
}

defer prepareTestEnv(t)()
KN4CK3R marked this conversation as resolved.
Show resolved Hide resolved

org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})

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": "user5@example.com",
})
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, "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": csrf,
})
resp = session.MakeRequest(t, req, http.StatusSeeOther)
req = NewRequest(t, "GET", test.RedirectURL(resp))
session.MakeRequest(t, req, http.StatusOK)
}
2 changes: 2 additions & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,8 @@ var migrations = []Migration{
NewMigration("Add badges to users", createUserBadgesTable),
// v225 -> v226
NewMigration("Alter gpg_key/public_key content TEXT fields to MEDIUMTEXT", alterPublicGPGKeyContentFieldsToMediumText),
// v226 -> v227
NewMigration("Add TeamInvite table", addTeamInviteTable),
}

// GetCurrentDBVersion returns the current db version
Expand Down
25 changes: 25 additions & 0 deletions models/migrations/v226.go
Original file line number Diff line number Diff line change
@@ -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))
}
22 changes: 6 additions & 16 deletions models/org_team.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion models/organization/org.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
KN4CK3R marked this conversation as resolved.
Show resolved Hide resolved
}
}

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 {
Expand Down
1 change: 1 addition & 0 deletions models/organization/team.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
148 changes: 148 additions & 0 deletions models/organization/team_invite.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// 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 {
KN4CK3R marked this conversation as resolved.
Show resolved Hide resolved
_, 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)
}

// 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"`
Token string `xorm:"UNIQUE(token) INDEX"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have a length limitation?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, at the moment the length limit should be whatever MySQL understands under TEXT, something like 4096 chars?

InviterID int64 `xorm:"NOT NULL"`
TeamID int64 `xorm:"UNIQUE(team_mail) INDEX NOT NULL"`
Email string `xorm:"UNIQUE(team_mail) NOT NULL"`
KN4CK3R marked this conversation as resolved.
Show resolved Hide resolved
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) {
KN4CK3R marked this conversation as resolved.
Show resolved Hide resolved
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,
TeamID: team.ID,
Email: email,
}

return invite, db.Insert(ctx, invite)
}

func RemoveInviteByID(ctx context.Context, inviteID, teamID int64) error {
_, err := db.DeleteByBean(ctx, &TeamInvite{
ID: inviteID,
TeamID: teamID,
})
return err
}

func GetInvitesByTeamID(ctx context.Context, teamID int64) ([]*TeamInvite, error) {
invites := make([]*TeamInvite, 0, 10)
return invites, db.GetEngine(ctx).
Where("team_id=?", teamID).
Find(&invites)
}

func GetInviteByToken(ctx context.Context, token string) (*TeamInvite, error) {
invite := &TeamInvite{}

has, err := db.GetEngine(ctx).Where("token=?", token).Get(invite)
if err != nil {
return nil, err
}
if !has {
return nil, ErrTeamInviteNotFound{Token: token}
}
return invite, nil
}
49 changes: 49 additions & 0 deletions models/organization/team_invite_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
11 changes: 11 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,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.
KN4CK3R marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down Expand Up @@ -484,6 +489,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
Expand Down Expand Up @@ -2389,6 +2395,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.
Expand All @@ -2413,6 +2421,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 the <strong>%s</strong> team of the <strong>%s</strong> organization.
KN4CK3R marked this conversation as resolved.
Show resolved Hide resolved
teams.invite.by = Invited by %s
teams.invite.description = Please click the following button to join the team.
KN4CK3R marked this conversation as resolved.
Show resolved Hide resolved

[admin]
dashboard = Dashboard
Expand Down
Loading