gitforge/models/organization/team_invite.go
Antonin Delpeuch c99b35cfa4 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>
2026-06-15 01:46:25 +02:00

228 lines
5.7 KiB
Go

// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package organization
import (
"context"
"fmt"
"forgejo.org/models/db"
user_model "forgejo.org/models/user"
"forgejo.org/modules/optional"
"forgejo.org/modules/timeutil"
"forgejo.org/modules/util"
"xorm.io/builder"
)
type ErrTeamInviteAlreadyExist struct {
TeamID int64
Email string
InvitedUserID int64
}
func IsErrTeamInviteAlreadyExist(err error) bool {
_, ok := err.(ErrTeamInviteAlreadyExist)
return ok
}
func (err ErrTeamInviteAlreadyExist) Error() string {
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 {
return util.ErrAlreadyExist
}
type ErrTeamInviteNotFound struct {
Token string
}
func IsErrTeamInviteNotFound(err error) bool {
_, ok := err.(ErrTeamInviteNotFound)
return ok
}
func (err ErrTeamInviteNotFound) Error() string {
return fmt.Sprintf("team invite was not found [token: %s]", err.Token)
}
func (err ErrTeamInviteNotFound) Unwrap() error {
return util.ErrNotExist
}
// 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.(ErrInvitedUserAlreadyAdded)
return ok
}
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 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 ''"`
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"`
}
// 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,
})
if err != nil {
return nil, err
}
if has {
return nil, ErrTeamInviteAlreadyExist{
TeamID: team.ID,
Email: email,
}
}
// check if the user is already a team member by email
exist, err := db.GetEngine(ctx).
Where(builder.Eq{
"team_user.org_id": team.OrgID,
"team_user.team_id": team.ID,
"`user`.email": email,
}).
Join("INNER", "`user`", "`user`.id = team_user.uid").
Table("team_user").
Exist()
if err != nil {
return nil, err
}
if exist {
return nil, ErrInvitedUserAlreadyAdded{
Email: email,
}
}
token := util.CryptoRandomString(util.RandomStringMedium)
invite := &TeamInvite{
Token: token,
InviterID: doer.ID,
OrgID: team.OrgID,
TeamID: team.ID,
Email: email,
}
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,
TeamID: teamID,
})
return err
}
func GetInvitesByTeamID(ctx context.Context, teamID int64) ([]*TeamInvite, error) {
invites := make([]*TeamInvite, 0, 10)
return invites, db.GetEngine(ctx).
Where("team_id=?", teamID).
Find(&invites)
}
func GetInviteByToken(ctx context.Context, token string) (*TeamInvite, error) {
invite := &TeamInvite{}
has, err := db.GetEngine(ctx).Where("token=?", token).Get(invite)
if err != nil {
return nil, err
}
if !has {
return nil, ErrTeamInviteNotFound{Token: token}
}
return invite, nil
}
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
}