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

@ -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", ""))
}