Skip to content

Commit e24c3f7

Browse files
bircniwxiaoguang
andauthored
Fix org contact email not clearable once set (#36975)
When the email field was submitted as empty in org settings (web and API), the previous guard `if form.Email != ""` silently skipped the update, making it impossible to remove a contact email after it was set. --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
1 parent 943ff75 commit e24c3f7

12 files changed

Lines changed: 227 additions & 159 deletions

File tree

modules/structs/org.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,23 +66,21 @@ type CreateOrgOption struct {
6666
RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access"`
6767
}
6868

69-
// TODO: make EditOrgOption fields optional after https://gitea.com/go-chi/binding/pulls/5 got merged
70-
7169
// EditOrgOption options for editing an organization
7270
type EditOrgOption struct {
7371
// The full display name of the organization
74-
FullName string `json:"full_name" binding:"MaxSize(100)"`
75-
// The email address of the organization
76-
Email string `json:"email" binding:"MaxSize(255)"`
72+
FullName *string `json:"full_name" binding:"MaxSize(100)"`
73+
// The email address of the organization; use empty string to clear
74+
Email *string `json:"email" binding:"MaxSize(255)"`
7775
// The description of the organization
78-
Description string `json:"description" binding:"MaxSize(255)"`
76+
Description *string `json:"description" binding:"MaxSize(255)"`
7977
// The website URL of the organization
80-
Website string `json:"website" binding:"ValidUrl;MaxSize(255)"`
78+
Website *string `json:"website" binding:"ValidUrl;MaxSize(255)"`
8179
// The location of the organization
82-
Location string `json:"location" binding:"MaxSize(50)"`
80+
Location *string `json:"location" binding:"MaxSize(50)"`
8381
// possible values are `public`, `limited` or `private`
8482
// enum: public,limited,private
85-
Visibility string `json:"visibility" binding:"In(,public,limited,private)"`
83+
Visibility *string `json:"visibility" binding:"In(,public,limited,private)"`
8684
// Whether repository administrators can change team access
8785
RepoAdminChangeTeamAccess *bool `json:"repo_admin_change_team_access"`
8886
}

routers/api/v1/org/org.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package org
66

77
import (
8+
"errors"
89
"net/http"
910

1011
activities_model "code.gitea.io/gitea/models/activities"
@@ -14,6 +15,7 @@ import (
1415
user_model "code.gitea.io/gitea/models/user"
1516
"code.gitea.io/gitea/modules/optional"
1617
api "code.gitea.io/gitea/modules/structs"
18+
"code.gitea.io/gitea/modules/util"
1719
"code.gitea.io/gitea/modules/web"
1820
"code.gitea.io/gitea/routers/api/v1/user"
1921
"code.gitea.io/gitea/routers/api/v1/utils"
@@ -379,19 +381,21 @@ func Edit(ctx *context.APIContext) {
379381

380382
form := web.GetForm(ctx).(*api.EditOrgOption)
381383

382-
if form.Email != "" {
383-
if err := user_service.ReplacePrimaryEmailAddress(ctx, ctx.Org.Organization.AsUser(), form.Email); err != nil {
384-
ctx.APIErrorInternal(err)
384+
if err := org.UpdateOrgEmailAddress(ctx, ctx.Org.Organization, form.Email); err != nil {
385+
if errors.Is(err, util.ErrInvalidArgument) {
386+
ctx.APIError(http.StatusUnprocessableEntity, err)
385387
return
386388
}
389+
ctx.APIErrorInternal(err)
390+
return
387391
}
388392

389393
opts := &user_service.UpdateOptions{
390-
FullName: optional.Some(form.FullName),
391-
Description: optional.Some(form.Description),
392-
Website: optional.Some(form.Website),
393-
Location: optional.Some(form.Location),
394-
Visibility: optional.FromMapLookup(api.VisibilityModes, form.Visibility),
394+
FullName: optional.FromPtr(form.FullName),
395+
Description: optional.FromPtr(form.Description),
396+
Website: optional.FromPtr(form.Website),
397+
Location: optional.FromPtr(form.Location),
398+
Visibility: optional.FromMapLookup(api.VisibilityModes, optional.FromPtr(form.Visibility).Value()),
395399
RepoAdminChangeTeamAccess: optional.FromPtr(form.RepoAdminChangeTeamAccess),
396400
}
397401
if err := user_service.UpdateUser(ctx, ctx.Org.Organization.AsUser(), opts); err != nil {

routers/web/org/setting.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package org
66

77
import (
8+
"errors"
89
"net/http"
910
"net/url"
1011

@@ -69,24 +70,25 @@ func SettingsPost(ctx *context.Context) {
6970
}
7071

7172
org := ctx.Org.Organization
72-
73-
if form.Email != "" {
74-
if err := user_service.ReplacePrimaryEmailAddress(ctx, org.AsUser(), form.Email); err != nil {
73+
if err := org_service.UpdateOrgEmailAddress(ctx, org, form.Email); err != nil {
74+
if errors.Is(err, util.ErrInvalidArgument) {
7575
ctx.Data["Err_Email"] = true
7676
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tplSettingsOptions, &form)
7777
return
7878
}
79+
ctx.ServerError("UpdateOrgEmailAddress", err)
80+
return
7981
}
8082

8183
opts := &user_service.UpdateOptions{
82-
FullName: optional.Some(form.FullName),
83-
Description: optional.Some(form.Description),
84-
Website: optional.Some(form.Website),
85-
Location: optional.Some(form.Location),
86-
RepoAdminChangeTeamAccess: optional.Some(form.RepoAdminChangeTeamAccess),
84+
FullName: optional.FromPtr(form.FullName),
85+
Description: optional.FromPtr(form.Description),
86+
Website: optional.FromPtr(form.Website),
87+
Location: optional.FromPtr(form.Location),
88+
RepoAdminChangeTeamAccess: optional.FromPtr(form.RepoAdminChangeTeamAccess),
8789
}
8890
if ctx.Doer.IsAdmin {
89-
opts.MaxRepoCreation = optional.Some(form.MaxRepoCreation)
91+
opts.MaxRepoCreation = optional.FromPtr(form.MaxRepoCreation)
9092
}
9193

9294
if err := user_service.UpdateUser(ctx, org.AsUser(), opts); err != nil {

services/forms/org.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,13 @@ func (f *CreateOrgForm) Validate(req *http.Request, errs binding.Errors) binding
3636

3737
// UpdateOrgSettingForm form for updating organization settings
3838
type UpdateOrgSettingForm struct {
39-
FullName string `binding:"MaxSize(100)"`
40-
Email string `binding:"MaxSize(255)"`
41-
Description string `binding:"MaxSize(255)"`
42-
Website string `binding:"ValidUrl;MaxSize(255)"`
43-
Location string `binding:"MaxSize(50)"`
44-
MaxRepoCreation int
45-
RepoAdminChangeTeamAccess bool
39+
FullName *string `binding:"MaxSize(100)"`
40+
Email *string `binding:"MaxSize(255)"`
41+
Description *string `binding:"MaxSize(255)"`
42+
Website *string `binding:"ValidUrl;MaxSize(255)"`
43+
Location *string `binding:"MaxSize(50)"`
44+
MaxRepoCreation *int
45+
RepoAdminChangeTeamAccess *bool
4646
}
4747

4848
// Validate validates the fields

services/org/org.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,20 @@ func ChangeOrganizationVisibility(ctx context.Context, org *org_model.Organizati
168168
return nil
169169
})
170170
}
171+
172+
// UpdateOrgEmailAddress validates and updates the organization's contact email.
173+
// A nil email means no change.
174+
func UpdateOrgEmailAddress(ctx context.Context, org *org_model.Organization, email *string) error {
175+
if email == nil {
176+
return nil
177+
}
178+
179+
if *email != "" {
180+
if err := user_model.ValidateEmail(*email); err != nil {
181+
return err
182+
}
183+
}
184+
185+
org.Email = *email
186+
return user_model.UpdateUserCols(ctx, org.AsUser(), "email")
187+
}

services/org/org_test.go

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,54 @@ import (
1010
repo_model "code.gitea.io/gitea/models/repo"
1111
"code.gitea.io/gitea/models/unittest"
1212
user_model "code.gitea.io/gitea/models/user"
13+
"code.gitea.io/gitea/modules/util"
1314

1415
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
1517
)
1618

1719
func TestMain(m *testing.M) {
1820
unittest.MainTest(m)
1921
}
2022

21-
func TestDeleteOrganization(t *testing.T) {
22-
assert.NoError(t, unittest.PrepareTestDatabase())
23-
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 6})
24-
assert.NoError(t, DeleteOrganization(t.Context(), org, false))
25-
unittest.AssertNotExistsBean(t, &organization.Organization{ID: 6})
26-
unittest.AssertNotExistsBean(t, &organization.OrgUser{OrgID: 6})
27-
unittest.AssertNotExistsBean(t, &organization.Team{OrgID: 6})
28-
29-
org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
30-
err := DeleteOrganization(t.Context(), org, false)
31-
assert.Error(t, err)
32-
assert.True(t, repo_model.IsErrUserOwnRepos(err))
33-
34-
user := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 5})
35-
assert.Error(t, DeleteOrganization(t.Context(), user, false))
36-
unittest.CheckConsistencyFor(t, &user_model.User{}, &organization.Team{})
23+
func TestOrg(t *testing.T) {
24+
require.NoError(t, unittest.PrepareTestDatabase())
25+
26+
t.Run("UpdateOrgEmailAddress", func(t *testing.T) {
27+
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
28+
originalEmail := org.Email
29+
30+
require.NoError(t, UpdateOrgEmailAddress(t.Context(), org, nil))
31+
unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3, Email: originalEmail})
32+
33+
newEmail := "contact@org3.example.com"
34+
require.NoError(t, UpdateOrgEmailAddress(t.Context(), org, &newEmail))
35+
unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3, Email: newEmail})
36+
37+
invalidEmail := "invalid email"
38+
err := UpdateOrgEmailAddress(t.Context(), org, &invalidEmail)
39+
require.ErrorIs(t, err, util.ErrInvalidArgument)
40+
unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3, Email: newEmail})
41+
42+
require.NoError(t, UpdateOrgEmailAddress(t.Context(), org, new("")))
43+
org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3, Email: ""})
44+
assert.Empty(t, org.Email)
45+
})
46+
47+
t.Run("DeleteOrganization", func(t *testing.T) {
48+
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 6})
49+
assert.NoError(t, DeleteOrganization(t.Context(), org, false))
50+
unittest.AssertNotExistsBean(t, &organization.Organization{ID: 6})
51+
unittest.AssertNotExistsBean(t, &organization.OrgUser{OrgID: 6})
52+
unittest.AssertNotExistsBean(t, &organization.Team{OrgID: 6})
53+
54+
org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
55+
err := DeleteOrganization(t.Context(), org, false)
56+
assert.Error(t, err)
57+
assert.True(t, repo_model.IsErrUserOwnRepos(err))
58+
59+
user := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 5})
60+
assert.Error(t, DeleteOrganization(t.Context(), user, false))
61+
unittest.CheckConsistencyFor(t, &user_model.User{}, &organization.Team{})
62+
})
3763
}

services/user/email.go

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ import (
1414
"code.gitea.io/gitea/modules/util"
1515
)
1616

17+
// ReplacePrimaryEmailAddress replaces the user's primary email address with the given email address.
18+
// It also updates the user's email field to match the new primary email address.
1719
func ReplacePrimaryEmailAddress(ctx context.Context, u *user_model.User, emailStr string) error {
20+
// FIXME: this check is from old logic, but it is not right, there are far more user types, not only "organization"
21+
if u.IsOrganization() {
22+
return util.NewInvalidArgumentErrorf("user %s is an organization", u.Name)
23+
}
24+
1825
if strings.EqualFold(u.Email, emailStr) {
1926
return nil
2027
}
@@ -24,41 +31,38 @@ func ReplacePrimaryEmailAddress(ctx context.Context, u *user_model.User, emailSt
2431
}
2532

2633
return db.WithTx(ctx, func(ctx context.Context) error {
27-
if !u.IsOrganization() {
28-
// Check if address exists already
29-
email, err := user_model.GetEmailAddressByEmail(ctx, emailStr)
30-
if err != nil && !errors.Is(err, util.ErrNotExist) {
31-
return err
32-
}
33-
if email != nil {
34-
if email.IsPrimary && email.UID == u.ID {
35-
return nil
36-
}
37-
return user_model.ErrEmailAlreadyUsed{Email: emailStr}
34+
// Check if address exists already
35+
email, err := user_model.GetEmailAddressByEmail(ctx, emailStr)
36+
if err != nil && !errors.Is(err, util.ErrNotExist) {
37+
return err
38+
}
39+
if email != nil {
40+
if email.IsPrimary && email.UID == u.ID {
41+
return nil
3842
}
43+
return user_model.ErrEmailAlreadyUsed{Email: emailStr}
44+
}
3945

40-
// Remove old primary address
41-
primary, err := user_model.GetPrimaryEmailAddressOfUser(ctx, u.ID)
42-
if err != nil {
43-
return err
44-
}
45-
if _, err := db.DeleteByID[user_model.EmailAddress](ctx, primary.ID); err != nil {
46-
return err
47-
}
46+
// Remove old primary address
47+
primary, err := user_model.GetPrimaryEmailAddressOfUser(ctx, u.ID)
48+
if err != nil {
49+
return err
50+
}
51+
if _, err := db.DeleteByID[user_model.EmailAddress](ctx, primary.ID); err != nil {
52+
return err
53+
}
4854

49-
// Insert new primary address
50-
if _, err := user_model.InsertEmailAddress(ctx, &user_model.EmailAddress{
51-
UID: u.ID,
52-
Email: emailStr,
53-
IsActivated: true,
54-
IsPrimary: true,
55-
}); err != nil {
56-
return err
57-
}
55+
// Insert new primary address
56+
if _, err := user_model.InsertEmailAddress(ctx, &user_model.EmailAddress{
57+
UID: u.ID,
58+
Email: emailStr,
59+
IsActivated: true,
60+
IsPrimary: true,
61+
}); err != nil {
62+
return err
5863
}
5964

6065
u.Email = emailStr
61-
6266
return user_model.UpdateUserCols(ctx, u, "email")
6367
})
6468
}

0 commit comments

Comments
 (0)