mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-07-14 05:28:39 +00:00
feat: setting to add team members by invitations (#12845)
Fixes #12564. Fixes #8951. This introduces a new setting, `ADD_MEMBERS_BY_INVITATIONS`, which is turned off by default. When turned on, adding a user to a team issues an invitation instead of adding them directly to the team. A prerequisite for this work was to be able to link invitations to existing users (so far, they were only associated to an email address, since those invitations were meant to be issued to users who didn't have an account yet). --- I plan to work on the following improvements, which I propose to do in separate PRs given that this one is already a bit big: * generate an in-app notification for the invited user * advertise the invitation to the invited user from the org page as well (#12120) * show the list of invited users in the list of organization members (not just on the team page) and various other improvements to invitations (#12570, #12716). Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12845 Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
parent
22a809a4e1
commit
c99b35cfa4
19 changed files with 592 additions and 43 deletions
26
models/forgejo_migrations/v16c_add_team_invite_invited_id.go
Normal file
26
models/forgejo_migrations/v16c_add_team_invite_invited_id.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package forgejo_migrations
|
||||
|
||||
import (
|
||||
"forgejo.org/modules/optional"
|
||||
|
||||
"code.forgejo.org/xorm/xorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerMigration(&Migration{
|
||||
Description: "add invited_id to team_invite",
|
||||
Upgrade: addTeamInviteInvitedID,
|
||||
})
|
||||
}
|
||||
|
||||
func addTeamInviteInvitedID(x *xorm.Engine) error {
|
||||
// the invited_id is set None if we are inviting someone who does not have an account yet
|
||||
type TeamInvite struct {
|
||||
InvitedID optional.Option[int64] `xorm:"index REFERENCES(user, id)"`
|
||||
}
|
||||
_, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(TeamInvite))
|
||||
return err
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import (
|
|||
|
||||
"forgejo.org/models/db"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/optional"
|
||||
"forgejo.org/modules/timeutil"
|
||||
"forgejo.org/modules/util"
|
||||
|
||||
|
|
@ -16,8 +17,9 @@ import (
|
|||
)
|
||||
|
||||
type ErrTeamInviteAlreadyExist struct {
|
||||
TeamID int64
|
||||
Email string
|
||||
TeamID int64
|
||||
Email string
|
||||
InvitedUserID int64
|
||||
}
|
||||
|
||||
func IsErrTeamInviteAlreadyExist(err error) bool {
|
||||
|
|
@ -26,7 +28,7 @@ func IsErrTeamInviteAlreadyExist(err error) bool {
|
|||
}
|
||||
|
||||
func (err ErrTeamInviteAlreadyExist) Error() string {
|
||||
return fmt.Sprintf("team invite already exists [team_id: %d, email: %s]", err.TeamID, err.Email)
|
||||
return fmt.Sprintf("team invite already exists [team_id: %d, email: %s, invited_user_id: %d]", err.TeamID, err.Email, err.InvitedUserID)
|
||||
}
|
||||
|
||||
func (err ErrTeamInviteAlreadyExist) Unwrap() error {
|
||||
|
|
@ -50,38 +52,42 @@ func (err ErrTeamInviteNotFound) Unwrap() error {
|
|||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// ErrUserEmailAlreadyAdded represents a "user by email already added to team" error.
|
||||
type ErrUserEmailAlreadyAdded struct {
|
||||
Email string
|
||||
// ErrInvitedUserAlreadyAdded indicates that a user is already part of a team and can not be invited again.
|
||||
type ErrInvitedUserAlreadyAdded struct {
|
||||
Email string
|
||||
InvitedUserID optional.Option[int64]
|
||||
}
|
||||
|
||||
// IsErrUserEmailAlreadyAdded checks if an error is a ErrUserEmailAlreadyAdded.
|
||||
func IsErrUserEmailAlreadyAdded(err error) bool {
|
||||
_, ok := err.(ErrUserEmailAlreadyAdded)
|
||||
_, ok := err.(ErrInvitedUserAlreadyAdded)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrUserEmailAlreadyAdded) Error() string {
|
||||
return fmt.Sprintf("user with email already added [email: %s]", err.Email)
|
||||
func (err ErrInvitedUserAlreadyAdded) Error() string {
|
||||
return fmt.Sprintf("user with email already added [email: %s, invited_user_id: %d]", err.Email, err.InvitedUserID)
|
||||
}
|
||||
|
||||
func (err ErrUserEmailAlreadyAdded) Unwrap() error {
|
||||
func (err ErrInvitedUserAlreadyAdded) 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"`
|
||||
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 ''"`
|
||||
InvitedID optional.Option[int64] `xorm:"index REFERENCES(user, id)"`
|
||||
InvitedUser *user_model.User `xorm:"-"`
|
||||
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) {
|
||||
// CreateTeamInviteByEmail creates a TeamInvite for someone who does not have an account yet.
|
||||
func CreateTeamInviteByEmail(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,
|
||||
|
|
@ -111,7 +117,7 @@ func CreateTeamInvite(ctx context.Context, doer *user_model.User, team *Team, em
|
|||
}
|
||||
|
||||
if exist {
|
||||
return nil, ErrUserEmailAlreadyAdded{
|
||||
return nil, ErrInvitedUserAlreadyAdded{
|
||||
Email: email,
|
||||
}
|
||||
}
|
||||
|
|
@ -129,6 +135,56 @@ func CreateTeamInvite(ctx context.Context, doer *user_model.User, team *Team, em
|
|||
return invite, db.Insert(ctx, invite)
|
||||
}
|
||||
|
||||
// CreateTeamInviteForUser creates a TeamInvite for someone who already has an account on the instance.
|
||||
func CreateTeamInviteForUser(ctx context.Context, doer, invited *user_model.User, team *Team) (*TeamInvite, error) {
|
||||
has, err := db.GetEngine(ctx).Exist(&TeamInvite{
|
||||
TeamID: team.ID,
|
||||
InvitedID: optional.Some(invited.ID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if has {
|
||||
return nil, ErrTeamInviteAlreadyExist{
|
||||
TeamID: team.ID,
|
||||
Email: invited.Email,
|
||||
}
|
||||
}
|
||||
|
||||
// check if the user is already a team member
|
||||
exist, err := db.GetEngine(ctx).
|
||||
Where(builder.Eq{
|
||||
"org_id": team.OrgID,
|
||||
"team_id": team.ID,
|
||||
"uid": invited.ID,
|
||||
}).
|
||||
Table("team_user").
|
||||
Exist()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if exist {
|
||||
return nil, ErrInvitedUserAlreadyAdded{
|
||||
InvitedUserID: optional.Some(invited.ID),
|
||||
}
|
||||
}
|
||||
|
||||
token := util.CryptoRandomString(util.RandomStringMedium)
|
||||
|
||||
invite := &TeamInvite{
|
||||
Token: token,
|
||||
InviterID: doer.ID,
|
||||
OrgID: team.OrgID,
|
||||
TeamID: team.ID,
|
||||
Email: invited.Email,
|
||||
InvitedID: optional.Some(invited.ID),
|
||||
InvitedUser: invited,
|
||||
}
|
||||
|
||||
return invite, db.Insert(ctx, invite)
|
||||
}
|
||||
|
||||
func RemoveInviteByID(ctx context.Context, inviteID, teamID int64) error {
|
||||
_, err := db.DeleteByBean(ctx, &TeamInvite{
|
||||
ID: inviteID,
|
||||
|
|
@ -156,3 +212,17 @@ func GetInviteByToken(ctx context.Context, token string) (*TeamInvite, error) {
|
|||
}
|
||||
return invite, nil
|
||||
}
|
||||
|
||||
func (i *TeamInvite) LoadInvitedUser(ctx context.Context) error {
|
||||
if i.InvitedUser == nil {
|
||||
hasInvitedUser, userID := i.InvitedID.Get()
|
||||
if hasInvitedUser {
|
||||
user, err := user_model.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.InvitedUser = user
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,19 +24,70 @@ func TestTeamInvite(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)
|
||||
_, err := organization.CreateTeamInviteByEmail(db.DefaultContext, user2, team, user2.Email)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("CreateAndRemove", func(t *testing.T) {
|
||||
t.Run("UserExistsInTeam", func(t *testing.T) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
// user 4 already added to team 2, should result in error
|
||||
_, err := organization.CreateTeamInviteForUser(db.DefaultContext, user2, user4, team)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("CreateAndRemoveByUser", func(t *testing.T) {
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
|
||||
|
||||
invite, err := organization.CreateTeamInviteForUser(db.DefaultContext, user1, user5, team)
|
||||
assert.NotNil(t, invite)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Shouldn't allow duplicate invite by email
|
||||
_, err = organization.CreateTeamInviteByEmail(db.DefaultContext, user1, team, user5.Email)
|
||||
require.Error(t, err)
|
||||
// Shouldn't allow duplicate invite by user
|
||||
_, err = organization.CreateTeamInviteForUser(db.DefaultContext, user1, user5, team)
|
||||
require.Error(t, err)
|
||||
|
||||
// should remove invite
|
||||
require.NoError(t, organization.RemoveInviteByID(db.DefaultContext, invite.ID, invite.TeamID))
|
||||
|
||||
// invite should not exist
|
||||
_, err = organization.GetInviteByToken(db.DefaultContext, invite.Token)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("CreateByEmailAndRemove", func(t *testing.T) {
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
|
||||
invite, err := organization.CreateTeamInvite(db.DefaultContext, user1, team, "org3@example.com")
|
||||
invite, err := organization.CreateTeamInviteByEmail(db.DefaultContext, user1, team, "org3@example.com")
|
||||
assert.NotNil(t, invite)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Shouldn't allow duplicate invite
|
||||
_, err = organization.CreateTeamInvite(db.DefaultContext, user1, team, "org3@example.com")
|
||||
_, err = organization.CreateTeamInviteByEmail(db.DefaultContext, user1, team, "org3@example.com")
|
||||
require.Error(t, err)
|
||||
|
||||
// should remove invite
|
||||
require.NoError(t, organization.RemoveInviteByID(db.DefaultContext, invite.ID, invite.TeamID))
|
||||
|
||||
// invite should not exist
|
||||
_, err = organization.GetInviteByToken(db.DefaultContext, invite.Token)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("CreateByUserAndRemove", func(t *testing.T) {
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
|
||||
invite, err := organization.CreateTeamInviteByEmail(db.DefaultContext, user1, team, "org3@example.com")
|
||||
assert.NotNil(t, invite)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Shouldn't allow duplicate invite
|
||||
_, err = organization.CreateTeamInviteByEmail(db.DefaultContext, user1, team, "org3@example.com")
|
||||
require.Error(t, err)
|
||||
|
||||
// should remove invite
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue