mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-06-22 10:02:15 +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
|
||||
|
|
|
|||
|
|
@ -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(",")
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
73
services/org/team_invite_test.go
Normal file
73
services/org/team_invite_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}}">
|
||||
|
|
|
|||
|
|
@ -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)})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", ""))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue