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:
Antonin Delpeuch 2026-06-15 01:46:25 +02:00 committed by Gusted
commit c99b35cfa4
19 changed files with 592 additions and 43 deletions

View 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
}

View file

@ -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
}

View file

@ -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

View file

@ -82,6 +82,7 @@ var Service = struct {
NoReplyAddress string
UserLocationMapURL string
EnableUserHeatmap bool
AddMembersByInvitations bool
AutoWatchNewRepos bool
AutoWatchOnChanges bool
DefaultOrgMemberVisible bool
@ -245,6 +246,7 @@ func loadServiceFrom(rootCfg ConfigProvider) {
Service.NoReplyAddress = sec.Key("NO_REPLY_ADDRESS").MustString("noreply." + Domain)
Service.UserLocationMapURL = sec.Key("USER_LOCATION_MAP_URL").MustString("https://www.openstreetmap.org/search?query=")
Service.EnableUserHeatmap = sec.Key("ENABLE_USER_HEATMAP").MustBool(true)
Service.AddMembersByInvitations = sec.Key("ADD_MEMBERS_BY_INVITATIONS").MustBool(false)
Service.AutoWatchNewRepos = sec.Key("AUTO_WATCH_NEW_REPOS").MustBool(true)
Service.AutoWatchOnChanges = sec.Key("AUTO_WATCH_ON_CHANGES").MustBool(false)
modes := sec.Key("ALLOWED_USER_VISIBILITY_MODES").Strings(",")

View file

@ -908,6 +908,8 @@
"teams.add_all_repos.modal.header": "Add all repositories",
"teams.remove_all_repos.modal.header": "Remove all repositories",
"members.add_member": "Add member",
"members.invite_member": "Invite member",
"members.invite_team_member": "Invite to team",
"members.user": "User",
"members.user_already_member": "This user is already a member of the organization.",
"members.no_team_selected": "Organization members must belong to at least one team.",

View file

@ -498,8 +498,8 @@ func AddTeamMember(ctx *context.APIContext) {
if ctx.Written() {
return
}
if err := models.AddTeamMember(ctx, ctx.Org.Team, u.ID); err != nil {
ctx.Error(http.StatusInternalServerError, "AddMember", err)
if err := org_service.InviteOrAddTeamMember(ctx, ctx.Doer, u, ctx.Org.Team); err != nil {
ctx.Error(http.StatusInternalServerError, "InviteOrAddTeamMember", err)
return
}
ctx.Status(http.StatusNoContent)

View file

@ -16,6 +16,7 @@ import (
"forgejo.org/modules/setting"
shared_user "forgejo.org/routers/web/shared/user"
"forgejo.org/services/context"
org_service "forgejo.org/services/org"
)
const (
@ -77,6 +78,7 @@ func Members(ctx *context.Context) {
ctx.Data["MembersIsPublicMember"] = membersIsPublic
ctx.Data["MembersIsUserOrgOwner"] = organization.IsUserOrgOwner(ctx, members, org.ID)
ctx.Data["MembersTwoFaStatus"] = members.GetTwoFaStatus(ctx)
ctx.Data["AddMembersByInvitations"] = setting.Service.AddMembersByInvitations
ctx.HTML(http.StatusOK, tplMembers)
}
@ -154,9 +156,9 @@ func MembersAction(ctx *context.Context) {
for _, team := range teams {
addToTeam := ctx.FormBool(fmt.Sprintf("team_%d", team.ID))
if addToTeam {
err = models.AddTeamMember(ctx, team, u.ID)
err = org_service.InviteOrAddTeamMember(ctx, ctx.Doer, u, team)
if err != nil {
ctx.ServerError("AddTeamMember", err)
ctx.ServerError("InviteOrAddTeamMember", err)
return
}
addedToTeam = true

View file

@ -133,7 +133,7 @@ func TeamsAction(ctx *context.Context) {
if err != nil {
if user_model.IsErrUserNotExist(err) {
if setting.MailService != nil && validation.ValidateEmail(uname) == nil {
if err := org_service.CreateTeamInvite(ctx, ctx.Doer, ctx.Org.Team, uname); err != nil {
if err := org_service.CreateTeamInviteByEmail(ctx, ctx.Doer, ctx.Org.Team, uname); err != nil {
if org_model.IsErrTeamInviteAlreadyExist(err) {
ctx.Flash.Error(ctx.Tr("form.duplicate_invite_to_team"))
} else if org_model.IsErrUserEmailAlreadyAdded(err) {
@ -162,7 +162,7 @@ func TeamsAction(ctx *context.Context) {
if ctx.Org.Team.IsMember(ctx, u.ID) {
ctx.Flash.Error(ctx.Tr("org.teams.add_duplicate_users"))
} else {
err = models.AddTeamMember(ctx, ctx.Org.Team, u.ID)
err = org_service.InviteOrAddTeamMember(ctx, ctx.Doer, u, ctx.Org.Team)
}
page = "team"
@ -371,6 +371,7 @@ func TeamMembers(ctx *context.Context) {
ctx.Data["Title"] = ctx.Org.Team.Name
ctx.Data["PageIsOrgTeams"] = true
ctx.Data["PageIsOrgTeamMembers"] = true
ctx.Data["AddMembersByInvitations"] = setting.Service.AddMembersByInvitations
if err := shared_user.LoadHeaderCount(ctx); err != nil {
ctx.ServerError("LoadHeaderCount", err)
@ -396,6 +397,12 @@ func TeamMembers(ctx *context.Context) {
ctx.ServerError("GetInvitesByTeamID", err)
return
}
for _, invite := range invites {
if invite.LoadInvitedUser(ctx) != nil {
ctx.ServerError("LoadInvitedUser", err)
return
}
}
ctx.Data["Invites"] = invites
ctx.Data["IsEmailInviteEnabled"] = setting.MailService != nil
@ -583,6 +590,12 @@ func TeamInvite(ctx *context.Context) {
return
}
linkedToUser, invitedUserID := invite.InvitedID.Get()
if linkedToUser && invitedUserID != ctx.Doer.ID {
ctx.NotFound("ErrTeamInviteNotFound", nil)
return
}
ctx.Data["Title"] = ctx.Tr("org.teams.invite_team_member", team.Name)
ctx.Data["Invite"] = invite
ctx.Data["Organization"] = org
@ -604,6 +617,12 @@ func TeamInvitePost(ctx *context.Context) {
return
}
linkedToUser, invitedUserID := invite.InvitedID.Get()
if linkedToUser && invitedUserID != ctx.Doer.ID {
ctx.NotFound("ErrTeamInviteNotFound", nil)
return
}
if err := models.AddTeamMember(ctx, team, ctx.Doer.ID); err != nil {
ctx.ServerError("AddTeamMember", err)
return

View file

@ -6,17 +6,37 @@ package org
import (
"context"
"forgejo.org/models"
org_model "forgejo.org/models/organization"
user_model "forgejo.org/models/user"
"forgejo.org/modules/setting"
"forgejo.org/services/mailer"
)
// CreateTeamInvite make a persistent invite in db and mail it
func CreateTeamInvite(ctx context.Context, inviter *user_model.User, team *org_model.Team, uname string) error {
invite, err := org_model.CreateTeamInvite(ctx, inviter, team, uname)
// CreateTeamInviteByEmail makes a persistent invite in db for someone without an account and mails it to them.
func CreateTeamInviteByEmail(ctx context.Context, inviter *user_model.User, team *org_model.Team, uname string) error {
invite, err := org_model.CreateTeamInviteByEmail(ctx, inviter, team, uname)
if err != nil {
return err
}
return mailer.MailTeamInvite(ctx, inviter, team, invite)
}
// CreateTeamInviteByUser makes a persistent invite in db for someone with an account already and mails it.
func CreateTeamInviteByUser(ctx context.Context, inviter, invited *user_model.User, team *org_model.Team) error {
invite, err := org_model.CreateTeamInviteForUser(ctx, inviter, invited, team)
if err != nil {
return err
}
// TODO: instead of only sending an email, also create an in-app notification
return mailer.MailTeamInvite(ctx, inviter, team, invite)
}
// InviteOrAddTeamMember invites the user to the team if all team changes should go through invites, or adds them directly otherwise.
func InviteOrAddTeamMember(ctx context.Context, inviter, invited *user_model.User, team *org_model.Team) error {
if setting.Service.AddMembersByInvitations && inviter.ID != invited.ID {
return CreateTeamInviteByUser(ctx, inviter, invited, team)
}
return models.AddTeamMember(ctx, team, invited.ID)
}

View file

@ -0,0 +1,73 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package org
import (
"testing"
"forgejo.org/models/db"
"forgejo.org/models/organization"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/optional"
"forgejo.org/modules/setting"
"forgejo.org/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_InviteTeamMember(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Service.AddMembersByInvitations, true)()
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
invited := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
inviter := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
require.NoError(t, InviteOrAddTeamMember(db.DefaultContext, inviter, invited, team))
unittest.AssertExistsAndLoadBean(t, &organization.TeamInvite{TeamID: team.ID, InviterID: inviter.ID, InvitedID: optional.Some(invited.ID)})
isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, invited.ID)
require.NoError(t, err)
assert.False(t, isMember)
}
func Test_AddTeamMember(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Service.AddMembersByInvitations, false)()
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
invited := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
inviter := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
require.NoError(t, InviteOrAddTeamMember(db.DefaultContext, inviter, invited, team))
unittest.AssertNotExistsBean(t, &organization.TeamInvite{TeamID: team.ID, InviterID: inviter.ID, InvitedID: optional.Some(invited.ID)})
isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, invited.ID)
require.NoError(t, err)
assert.True(t, isMember)
}
func Test_SelfInvite(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Service.AddMembersByInvitations, true)()
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
inviter := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, inviter.ID)
require.NoError(t, err)
assert.False(t, isMember)
// the inviter invites themselves to a team (they are owner of the org)
require.NoError(t, InviteOrAddTeamMember(db.DefaultContext, inviter, inviter, team))
// even though `ADD_MEMBERS_BY_INVITATIONS` is on, we don't generate an invite to the inviter,
// they are added to the team directly
unittest.AssertNotExistsBean(t, &organization.TeamInvite{TeamID: team.ID, InviterID: inviter.ID, InvitedID: optional.Some(inviter.ID)})
isMember, err = organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, inviter.ID)
require.NoError(t, err)
assert.True(t, isMember)
}

View file

@ -88,6 +88,8 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error)
&user_model.UserOpenID{UID: u.ID},
&issues_model.Reaction{UserID: u.ID},
&organization.TeamUser{UID: u.ID},
&organization.TeamInvite{InviterID: u.ID},
&organization.TeamInvite{InvitedID: optional.Some(u.ID)},
&issues_model.Stopwatch{UserID: u.ID},
&user_model.Setting{UserID: u.ID},
&user_model.UserBadge{UserID: u.ID},

View file

@ -11,7 +11,13 @@
<dialog id="add-member-modal">
<article>
<header>{{ctx.Locale.Tr "members.add_member"}}</header>
<header>
{{if $.AddMembersByInvitations}}
{{ctx.Locale.Tr "members.invite_member"}}
{{else}}
{{ctx.Locale.Tr "members.add_member"}}
{{end}}
</header>
<form class="ui add-member form ignore-dirty" action="{{$.OrgLink}}/members/action/add" method="post">
<input type="hidden" name="uid" value="{{.SignedUser.ID}}">
<div class="content">
@ -49,7 +55,13 @@
{{svg "octicon-x"}}
{{ctx.Locale.Tr "cancel"}}
</button>
<button class="primary ok button" type="submit">{{ctx.Locale.Tr "members.add_member"}}</button>
<button class="primary ok button" type="submit">
{{if $.AddMembersByInvitations}}
{{ctx.Locale.Tr "members.invite_member"}}
{{else}}
{{ctx.Locale.Tr "members.add_member"}}
{{end}}
</button>
</div>
</article>
</dialog>

View file

@ -7,7 +7,12 @@
{{if $.IsOrganizationOwner}}
<div class="text right">
<button class="primary button" id="add-org-member-button">
{{svg "octicon-plus"}} {{ctx.Locale.Tr "members.add_member"}}
{{svg "octicon-plus"}}
{{if $.AddMembersByInvitations}}
{{ctx.Locale.Tr "members.invite_member"}}
{{else}}
{{ctx.Locale.Tr "members.add_member"}}
{{end}}
</button>
</div>
<div class="divider"></div>

View file

@ -16,7 +16,13 @@
<input class="prompt" name="uname" placeholder="{{ctx.Locale.Tr "search.user_kind"}}" autocomplete="off" required>
</div>
</div>
<button class="ui primary button">{{ctx.Locale.Tr "org.teams.add_team_member"}}</button>
<button class="ui primary button">
{{if $.AddMembersByInvitations}}
{{ctx.Locale.Tr "members.invite_team_member"}}
{{else}}
{{ctx.Locale.Tr "org.teams.add_team_member"}}
{{end}}
</button>
</form>
</div>
{{end}}
@ -57,9 +63,20 @@
<div class="flex-list">
{{range .Invites}}
<div class="flex-item tw-items-center">
<div class="flex-item-main">
{{.Email}}
</div>
{{if .InvitedUser}}
<div class="flex-item-leading">
<a href="{{.InvitedUser.HomeLink}}">{{ctx.AvatarUtils.Avatar .InvitedUser 32}}</a>
</div>
<div class="flex-item-main">
<div class="flex-item-title">
{{template "shared/user/name" .InvitedUser}}
</div>
</div>
{{else}}
<div class="flex-item-main">
{{.Email}}
</div>
{{end}}
<div class="flex-item-trailing">
<form action="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/remove_invite" method="post">
<input type="hidden" name="iid" value="{{.ID}}">

View file

@ -17,7 +17,10 @@ import (
"forgejo.org/models/unit"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/optional"
"forgejo.org/modules/setting"
api "forgejo.org/modules/structs"
"forgejo.org/modules/test"
"forgejo.org/services/convert"
"forgejo.org/tests"
@ -484,3 +487,39 @@ func TestAPIGetTeamRepoAccessTokenResources(t *testing.T) {
})
})
}
func TestAPIAddMemberDirectly(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Service.AddMembersByInvitations, false)()
token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteOrganization)
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
req := NewRequestf(t, "PUT", "/api/v1/teams/%d/members/%s", team.ID, user5.Name).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user5.ID)
require.NoError(t, err)
assert.True(t, isMember)
}
func TestAPIAddMemberGeneratesInvite(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Service.AddMembersByInvitations, true)()
token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteOrganization)
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
req := NewRequestf(t, "PUT", "/api/v1/teams/%d/members/%s", team.ID, user5.Name).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user5.ID)
require.NoError(t, err)
assert.False(t, isMember)
unittest.AssertExistsAndLoadBean(t, &organization.TeamInvite{TeamID: team.ID, InviterID: 1, InvitedID: optional.Some(user5.ID)})
}

View file

@ -0,0 +1,21 @@
-
id: 1
token: "T3jOj8VZi-vK3LPGvav3EM"
inviter_id: 1
org_id: 3
team_id: 2
email: "user31@example.com"
invited_id: 31
created_unix: 1778862588
updated_unix: 1778862588
-
id: 2
token: "D89db371f-uDtb824cBl9t"
inviter_id: 1
org_id: 3
team_id: 2
email: "external_user@example.com"
invited_id: null
created_unix: 1778862590
updated_unix: 1778862590

View file

@ -13,6 +13,9 @@ import (
"forgejo.org/models/organization"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/optional"
"forgejo.org/modules/setting"
"forgejo.org/modules/test"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
@ -56,7 +59,18 @@ func TestOrgMembersPage(t *testing.T) {
assert.Less(t, 2, doc.Find(".members .list .link-action").Length())
assert.Less(t, 2, doc.Find(".members .list .delete-button").Length())
/* Adding new members is possible */
doc.AssertElement(t, "#add-org-member-button", true)
assert.Equal(t, "Add member", strings.TrimSpace(doc.Find("#add-org-member-button").Text()))
})
t.Run("Owner PoV with ADD_MEMBERS_BY_INVITATIONS setting on", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer test.MockVariableValue(&setting.Service.AddMembersByInvitations, true)()
session := loginUser(t, "user2") // user2 owns org3
doc := NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
/* Inviting new members is possible */
assert.Equal(t, "Invite member", strings.TrimSpace(doc.Find("#add-org-member-button").Text()))
})
}
@ -221,3 +235,42 @@ func TestOrgAddExistingMemberFails(t *testing.T) {
require.NoError(t, err)
assert.False(t, isTeamMember)
}
func TestOrgAddMemberGeneratesAnInvite(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Service.AddMembersByInvitations, true)()
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
team1 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1})
team2 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
isMember, err := organization.IsTeamMember(db.DefaultContext, team1.OrgID, team1.ID, user.ID)
require.NoError(t, err)
assert.False(t, isMember)
isMember, err = organization.IsTeamMember(db.DefaultContext, team2.OrgID, team2.ID, user.ID)
require.NoError(t, err)
assert.False(t, isMember)
session := loginUser(t, "user2")
teamURL := fmt.Sprintf("/org/%s/members", org.Name)
req := NewRequestWithValues(t, "POST", teamURL+"/action/add", map[string]string{
"uid": "2",
"uname": user.LoginName,
"team_1": "on",
"team_2": "on",
})
resp := session.MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, teamURL, resp.Header().Get("Location"))
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
isMember, err = organization.IsTeamMember(db.DefaultContext, team1.OrgID, team1.ID, user.ID)
require.NoError(t, err)
assert.False(t, isMember)
isMember, err = organization.IsTeamMember(db.DefaultContext, team2.OrgID, team2.ID, user.ID)
require.NoError(t, err)
assert.False(t, isMember)
unittest.AssertExistsAndLoadBean(t, &organization.TeamInvite{TeamID: team1.ID, InviterID: 2, InvitedID: optional.Some(user.ID)})
unittest.AssertExistsAndLoadBean(t, &organization.TeamInvite{TeamID: team2.ID, InviterID: 2, InvitedID: optional.Some(user.ID)})
}

View file

@ -360,3 +360,89 @@ func TestOrgTeamEmailInviteRedirectsExistingUserWithLogin(t *testing.T) {
require.NoError(t, err)
assert.True(t, isMember)
}
// Test that a user can accept a team invite linked to their existing account
func TestOrgTeamEmailInviteExistingUser(t *testing.T) {
if setting.MailService == nil {
t.Skip()
return
}
defer tests.PrepareTestEnv(t)()
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
inviter := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID)
require.NoError(t, err)
assert.False(t, isMember)
// create the invite
invite, err := organization.CreateTeamInviteForUser(db.DefaultContext, inviter, user, team)
require.NoError(t, err)
// log in the invited user
session := loginUser(t, "user5")
// view the invite
inviteURL := fmt.Sprintf("/org/invite/%s", invite.Token)
req := NewRequest(t, "GET", inviteURL)
session.MakeRequest(t, req, http.StatusOK)
// accept the invite
req = NewRequest(t, "POST", inviteURL)
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)
require.NoError(t, err)
assert.True(t, isMember)
}
// Test that a user cannot accept an invite if it was meant for another user
func TestOrgTeamEmailInviteCannotBeAcceptedByOtherUser(t *testing.T) {
if setting.MailService == nil {
t.Skip()
return
}
defer tests.PrepareTestEnv(t)()
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
inviter := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
invited := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
attacker := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 11})
isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, invited.ID)
require.NoError(t, err)
assert.False(t, isMember)
isMember, err = organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, attacker.ID)
require.NoError(t, err)
assert.False(t, isMember)
// create the invite for the invited user
invite, err := organization.CreateTeamInviteForUser(db.DefaultContext, inviter, invited, team)
require.NoError(t, err)
// log in as the attacker
session := loginUser(t, attacker.Name)
// viewing the invite doesn't work
inviteURL := fmt.Sprintf("/org/invite/%s", invite.Token)
req := NewRequest(t, "GET", inviteURL)
session.MakeRequest(t, req, http.StatusNotFound)
// accepting the invite doesn't either
req = NewRequest(t, "POST", inviteURL)
session.MakeRequest(t, req, http.StatusNotFound)
// neither the invited user nor the attacker are part of the team
isMember, err = organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, invited.ID)
require.NoError(t, err)
assert.False(t, isMember)
isMember, err = organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, attacker.ID)
require.NoError(t, err)
assert.False(t, isMember)
}

View file

@ -72,3 +72,52 @@ func TestPaginatedRepos(t *testing.T) {
doc := NewHTMLParser(t, body)
assert.Contains(t, strings.TrimSpace(doc.Find("a.item.navigation:contains('Next')").AttrOr("href", "")), fmt.Sprintf("%s?page=2", teamURL))
}
func TestDisplayInvites(t *testing.T) {
defer unittest.OverrideFixtures("tests/integration/fixtures/TestDisplayInvites")()
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: 1})
session := loginUser(t, user.Name)
teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.LowerName)
body := session.MakeRequest(t, NewRequest(t, "GET", teamURL), http.StatusOK).Body
doc := NewHTMLParser(t, body)
// the two invited users are shown
assert.Equal(t, "/user31", doc.Find("a:contains('user31')").AttrOr("href", ""))
assert.Equal(t, 1, doc.Find("div.flex-item-main:contains('external_user@example.com')").Length())
}
func TestAddMembersByInvitations(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Service.AddMembersByInvitations, true)()
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
session := loginUser(t, user.Name)
teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.LowerName)
body := session.MakeRequest(t, NewRequest(t, "GET", teamURL), http.StatusOK).Body
// the button to add a team member says "invite"
doc := NewHTMLParser(t, body)
doc.AssertElement(t, "button.primary:contains('Invite to team')", true)
// invite user "user31" to the team
req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/action/add", teamURL), map[string]string{
"uname": "user31",
})
resp := session.MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, teamURL, resp.Header().Get("Location"))
// the invited user is listed on the team page
body = session.MakeRequest(t, NewRequest(t, "GET", teamURL), http.StatusOK).Body
doc = NewHTMLParser(t, body)
assert.Equal(t, "/user31", doc.Find("a:contains('user31')").AttrOr("href", ""))
}