From 666e6e2d17d4aa3eb867fc819487a97063d4a791 Mon Sep 17 00:00:00 2001 From: Alan Hsu Date: Wed, 4 Nov 2026 01:42:41 +0000 Subject: [PATCH] fix(web): allow clearing the full name in profile settings (#40186) Clearing "Full Name" in the profile settings shows success but keeps the old value: since #31959 it is only updated when non-empty, because the input is disabled (not submitted) when `change_full_name` is disabled. Use `*string` for the field, so a missing field keeps the value and an empty one clears it. With `change_full_name` disabled, any submitted value (including empty) is now rejected. Not `optional.Option[string]`, because `MaxSize` would count slice elements instead of characters. Fixes #40185 --- AI disclosure: written with Claude Code (claude-opus-5-5) and tested locally. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: wxiaoguang Co-authored-by: silverwind --- modules/optional/option.go | 2 + routers/web/user/setting/profile.go | 54 ++--- routers/web/web.go | 2 +- services/forms/user_form.go | 4 +- templates/user/settings/profile.tmpl | 2 +- tests/e2e/user-settings.test.ts | 2 + .../api_packages_container_test.go | 4 +- tests/integration/privateactivity_test.go | 2 +- tests/integration/user_settings_test.go | 207 +++++++++++++++--- tests/integration/user_test.go | 120 +--------- 10 files changed, 206 insertions(+), 193 deletions(-) diff --git a/modules/optional/option.go b/modules/optional/option.go index a278723bef2..be042632691 100644 --- a/modules/optional/option.go +++ b/modules/optional/option.go @@ -11,6 +11,8 @@ import "strconv" // * non-existing value are represented as an empty slice (None) // * existing value is represented as a slice with one element (Some) // * multiple values are represented as a slice with multiple elements (Some), the Value is the first element (not well-defined in this case) +// +// Attention: Option[T] doesn't work with some web middleware binding rules like "MaxSize" at the moment. type Option[T any] []T func None[T any]() Option[T] { diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index 99cb7db5495..cba181eb7c7 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -54,71 +54,55 @@ func Profile(ctx *context.Context) { // ProfilePost response for change user's profile func ProfilePost(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("settings_title") - ctx.Data["PageIsSettingsProfile"] = true - ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() - ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) - - if ctx.HasError() { - ctx.HTML(http.StatusOK, tplSettingsProfile) + form := context.GetFetchActionForm[*forms.UpdateProfileForm](ctx) + if form == nil { return } - form := web.GetForm[*forms.UpdateProfileForm](ctx) - if form.Name != "" { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureChangeUsername) { - ctx.Flash.Error(ctx.Tr("user.form.change_username_disabled")) - ctx.Redirect(setting.AppSubURL + "/user/settings") + ctx.JSONErrorWithField(ctx.Tr("form.change_username_disabled"), "Name") return } if err := user_service.RenameUser(ctx, ctx.Doer, form.Name, ctx.Doer); err != nil { switch { case user_model.IsErrUserIsNotLocal(err): - ctx.Flash.Error(ctx.Tr("form.username_change_not_local_user")) + ctx.JSONErrorWithField(ctx.Tr("form.username_change_not_local_user"), "Name") case user_model.IsErrUserAlreadyExist(err): - ctx.Flash.Error(ctx.Tr("form.username_been_taken")) + ctx.JSONErrorWithField(ctx.Tr("form.username_been_taken"), "Name") case db.IsErrNameReserved(err): - ctx.Flash.Error(ctx.Tr("user.form.name_reserved", form.Name)) + ctx.JSONErrorWithField(ctx.Tr("user.form.name_reserved", form.Name), "Name") case db.IsErrNamePatternNotAllowed(err): - ctx.Flash.Error(ctx.Tr("user.form.name_pattern_not_allowed", form.Name)) + ctx.JSONErrorWithField(ctx.Tr("user.form.name_pattern_not_allowed", form.Name), "Name") case db.IsErrNameCharsNotAllowed(err): - ctx.Flash.Error(ctx.Tr("user.form.name_chars_not_allowed", form.Name)) + ctx.JSONErrorWithField(ctx.Tr("user.form.name_chars_not_allowed", form.Name), "Name") default: - ctx.ServerError("RenameUser", err) - return + ctx.JSONErrorAuto(err) } - ctx.Redirect(setting.AppSubURL + "/user/settings") return } } - opts := &user_service.UpdateOptions{ + if form.FullName != nil && user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureChangeFullName) { + ctx.JSONErrorWithField(ctx.Tr("form.change_full_name_disabled"), "FullName") + return + } + + if err := user_service.UpdateUser(ctx, ctx.Doer, &user_service.UpdateOptions{ KeepEmailPrivate: optional.Some(form.KeepEmailPrivate), + FullName: optional.FromPtr(form.FullName), Description: optional.Some(form.Description), Website: optional.Some(form.Website), Location: optional.Some(form.Location), Visibility: optional.Some(form.Visibility), KeepActivityPrivate: optional.Some(form.KeepActivityPrivate), - } - - if form.FullName != "" { - if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureChangeFullName) { - ctx.Flash.Error(ctx.Tr("user.form.change_full_name_disabled")) - ctx.Redirect(setting.AppSubURL + "/user/settings") - return - } - opts.FullName = optional.Some(form.FullName) - } - - if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { - ctx.ServerError("UpdateUser", err) + }); err != nil { + ctx.JSONErrorAuto(err) return } - log.Trace("User settings updated: %s", ctx.Doer.Name) ctx.Flash.Success(ctx.Tr("settings.update_profile_success")) - ctx.Redirect(setting.AppSubURL + "/user/settings") + ctx.JSONRedirect(setting.AppSubURL + "/user/settings") } // UpdateAvatarSetting update user's avatar diff --git a/routers/web/web.go b/routers/web/web.go index 33fc62a2f56..3cf5efc3600 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -635,7 +635,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Group("/user/settings", func() { m.Get("", user_setting.Profile) - m.Post("", web.Bind[*forms.UpdateProfileForm](), user_setting.ProfilePost) + m.Post("", user_setting.ProfilePost) m.Post("/update_preferences", user_setting.UpdatePreferences) m.Get("/change_password", auth.MustChangePassword) m.Post("/change_password", web.Bind[*forms.MustChangePasswordForm](), auth.MustChangePasswordPost) diff --git a/services/forms/user_form.go b/services/forms/user_form.go index c39150575d6..fda746a8843 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -160,8 +160,8 @@ type IntrospectTokenForm struct { // UpdateProfileForm form for updating profile type UpdateProfileForm struct { middleware.FormDefaultValidator - Name string `binding:"Username;MaxSize(40)"` - FullName string `binding:"MaxSize(100)"` + Name string `binding:"Username;MaxSize(40)"` + FullName *string `binding:"MaxSize(100)"` KeepEmailPrivate bool Website string `binding:"ValidSiteUrl;MaxSize(255)"` Location string `binding:"MaxSize(50)"` diff --git a/templates/user/settings/profile.tmpl b/templates/user/settings/profile.tmpl index 2a43688197d..4a418ab5c7a 100644 --- a/templates/user/settings/profile.tmpl +++ b/templates/user/settings/profile.tmpl @@ -5,7 +5,7 @@

{{ctx.Locale.Tr "settings.profile_desc"}}

-
+