From 85eaf5c71c2049a06aa8d94b8cda9f47e63fa533 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 18 Sep 2026 17:11:56 +0200 Subject: [PATCH] refactor(api): convert bot accounts through the admin user edit endpoint (#39355) Follow-up to https://github.com/go-gitea/gitea/pull/38966. Replaces the unreleased `POST /admin/users/{username}/convert-type` endpoint with a `type` field on `PATCH /admin/users/{username}`. --- modules/structs/admin_user.go | 11 +-- modules/structs/user.go | 2 +- modules/structs/user_type.go | 2 +- options/locale/locale_en-US.json | 1 - routers/api/v1/admin/user.go | 58 ++++-------- routers/api/v1/api.go | 1 - routers/api/v1/swagger/options.go | 3 - routers/web/admin/users.go | 3 - services/convert/user.go | 4 +- templates/swagger/v1-openapi3.generated.json | 93 +++++--------------- templates/swagger/v1-swagger.generated.json | 78 +++------------- tests/integration/admin_user_test.go | 4 +- 12 files changed, 57 insertions(+), 203 deletions(-) diff --git a/modules/structs/admin_user.go b/modules/structs/admin_user.go index 8cc645fa2ae..c237977bf6a 100644 --- a/modules/structs/admin_user.go +++ b/modules/structs/admin_user.go @@ -76,13 +76,6 @@ type EditUserOption struct { Restricted *bool `json:"restricted"` // User visibility level: public, limited, or private Visibility VisibilityString `json:"visibility" binding:"In(,public,limited,private)"` -} - -// ConvertUserTypeOption options when converting a user between individual and bot -type ConvertUserTypeOption struct { - // The target user type - // - // required: true - // enum: ["User","Bot"] - UserType UserTypeString `json:"user_type" binding:"Required;In(User,Bot)"` + // The user type + Type UserTypeString `json:"type" binding:"In(User,Organization,Bot)"` } diff --git a/modules/structs/user.go b/modules/structs/user.go index 4609e181207..fe52fb8334d 100644 --- a/modules/structs/user.go +++ b/modules/structs/user.go @@ -17,7 +17,7 @@ type User struct { ID int64 `json:"id"` // login of the user, same as `username` UserName string `json:"login"` - // the account type + // the user type Type UserTypeString `json:"type"` // identifier of the user, provided by the external authenticator (if configured) LoginName string `json:"login_name"` diff --git a/modules/structs/user_type.go b/modules/structs/user_type.go index 2c987427af7..ce68e6e4919 100644 --- a/modules/structs/user_type.go +++ b/modules/structs/user_type.go @@ -3,7 +3,7 @@ package structs -// UserTypeString defines the account type as rendered in API responses, webhook payloads and +// UserTypeString defines the user type as rendered in API responses, webhook payloads and // the resulting GitHub Actions event context, where workflows read it as `github.event.sender.type`. // The values are capitalized to stay compatible with GitHub, unlike VisibilityString and the other // lowercase API enums. The DB representation is user.UserType (int). diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index e5492de8170..fffb963fbea 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3083,7 +3083,6 @@ "admin.users.impersonate_stop": "Stop impersonating", "admin.users.impersonating_notice": "You are impersonating %s. Actions you take are performed as this user.", "admin.users.user_type": "User Type", - "admin.users.convert_type.not_convertible": "This user type cannot be converted. Only user and bot accounts support type conversion.", "admin.users.convert_type.admin_not_allowed": "Administrators cannot be converted into bot accounts. Remove the administrator permission first.", "admin.users.bot_token_desc": "Bot accounts cannot sign in, so their access tokens are managed here by administrators.", "admin.users.bot_token_only": "Access tokens can only be generated for bot accounts here.", diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index c1c25685ec7..8c3a4264a03 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -190,6 +190,16 @@ func EditUser(ctx *context.APIContext) { form := web.GetForm[*api.EditUserOption](ctx) + var userType optional.Option[user_model.UserType] + if form.Type != "" && form.Type != convert.UserTypeToString(ctx.ContextUser.Type) { + newType, err := convert.UserTypeFromString(form.Type) + if err != nil { + ctx.APIErrorAuto(err) + return + } + userType = optional.Some(newType) + } + authOpts := &user_service.UpdateAuthOptions{ LoginSource: optional.FromNonDefault(form.SourceID), LoginName: optional.FromPtr(form.LoginName), @@ -197,6 +207,10 @@ func EditUser(ctx *context.APIContext) { MustChangePassword: optional.FromPtr(form.MustChangePassword), ProhibitLogin: optional.FromPtr(form.ProhibitLogin), } + if userType.Value() == user_model.UserTypeBot && (authOpts.Password.Has() || authOpts.LoginSource.Value() != 0 || authOpts.LoginName.Value() != "") { + ctx.APIError(http.StatusBadRequest, "a bot account cannot have a password or authentication source") + return + } if err := user_service.UpdateAuth(ctx, ctx.ContextUser, authOpts); err != nil { switch { case errors.Is(err, password.ErrMinLength): @@ -231,6 +245,7 @@ func EditUser(ctx *context.APIContext) { MaxRepoCreation: optional.FromPtr(form.MaxRepoCreation), AllowCreateOrganization: optional.FromPtr(form.AllowCreateOrganization), IsRestricted: optional.FromPtr(form.Restricted), + UserType: userType, } if err := user_service.UpdateUser(ctx, ctx.ContextUser, opts); err != nil { @@ -552,46 +567,3 @@ func RenameUser(ctx *context.APIContext) { } ctx.Status(http.StatusNoContent) } - -// ConvertUserType converts an account between the user and bot types -func ConvertUserType(ctx *context.APIContext) { - // swagger:operation POST /admin/users/{username}/convert-type admin adminConvertUserType - // --- - // summary: Convert an account between the user and bot types - // consumes: - // - application/json - // produces: - // - application/json - // parameters: - // - name: username - // in: path - // description: username of the user to convert - // type: string - // required: true - // - name: body - // in: body - // required: true - // schema: - // "$ref": "#/definitions/ConvertUserTypeOption" - // responses: - // "204": - // "$ref": "#/responses/empty" - // "400": - // "$ref": "#/responses/error" - // "403": - // "$ref": "#/responses/forbidden" - // "404": - // "$ref": "#/responses/notFound" - - targetType, err := convert.UserTypeFromString(web.GetForm[*api.ConvertUserTypeOption](ctx).UserType) - if err != nil { - ctx.APIErrorAuto(err) - return - } - - if err := user_service.UpdateUser(ctx, ctx.ContextUser, &user_service.UpdateOptions{UserType: optional.Some(targetType)}); err != nil { - ctx.APIErrorAuto(err) - return - } - ctx.Status(http.StatusNoContent) -} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index f42116d6683..8953647a99a 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1905,7 +1905,6 @@ func Routes() *web.Router { m.Post("/orgs", bind(api.CreateOrgOption{}), admin.CreateOrg) m.Post("/repos", bind(api.CreateRepoOption{}), admin.CreateRepo) m.Post("/rename", bind(api.RenameUserOption{}), admin.RenameUser) - m.Post("/convert-type", bind(api.ConvertUserTypeOption{}), admin.ConvertUserType) m.Get("/badges", admin.ListUserBadges) m.Post("/badges", bind(api.UserBadgeOption{}), admin.AddUserBadges) m.Delete("/badges", bind(api.UserBadgeOption{}), admin.DeleteUserBadges) diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 6a1eaaede60..0522fcec680 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -59,9 +59,6 @@ type swaggerParameterBodies struct { // in:body RenameUserOption api.RenameUserOption - // in:body - ConvertUserTypeOption api.ConvertUserTypeOption - // in:body CreateLabelOption api.CreateLabelOption // in:body diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index a777eaa25c0..6ced93e9e0a 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -506,9 +506,6 @@ func EditUserPost(ctx *context.Context) { case errors.Is(err, user_model.ErrBotCanNotBeAdmin): ctx.Flash.Error(ctx.Tr("admin.users.convert_type.admin_not_allowed")) ctx.Redirect(userLink) - case errors.Is(err, user_model.ErrUserTypeCanNotConvert): - ctx.Flash.Error(ctx.Tr("admin.users.convert_type.not_convertible")) - ctx.Redirect(userLink) case errors.Is(err, util.ErrInvalidArgument): ctx.Flash.Error(err.Error()) ctx.Redirect(userLink) diff --git a/services/convert/user.go b/services/convert/user.go index b7017331016..a99fcb8e69f 100644 --- a/services/convert/user.go +++ b/services/convert/user.go @@ -12,7 +12,7 @@ import ( "gitea.dev/modules/util" ) -func userTypeToString(t user_model.UserType) api.UserTypeString { +func UserTypeToString(t user_model.UserType) api.UserTypeString { switch t { case user_model.UserTypeOrganization, user_model.UserTypeOrganizationReserved: return api.UserTypeStringOrganization @@ -74,7 +74,7 @@ func toUser(ctx context.Context, user *user_model.User, signed, authed bool) *ap result := &api.User{ ID: user.ID, UserName: user.Name, - Type: userTypeToString(user.Type), + Type: UserTypeToString(user.Type), FullName: user.FullName, Email: user.GetPlaceholderEmail(), AvatarURL: user.AvatarLink(ctx), diff --git a/templates/swagger/v1-openapi3.generated.json b/templates/swagger/v1-openapi3.generated.json index b8f33d429e9..f0a12632662 100644 --- a/templates/swagger/v1-openapi3.generated.json +++ b/templates/swagger/v1-openapi3.generated.json @@ -3707,25 +3707,6 @@ "type": "object", "x-go-package": "gitea.dev/modules/structs" }, - "ConvertUserTypeOption": { - "description": "ConvertUserTypeOption options when converting a user between individual and bot", - "properties": { - "user_type": { - "description": "The target user type", - "enum": [ - "User", - "Bot" - ], - "type": "string", - "x-go-name": "UserType" - } - }, - "required": [ - "user_type" - ], - "type": "object", - "x-go-package": "gitea.dev/modules/structs" - }, "CreateAccessTokenOption": { "description": "CreateAccessTokenOption options when create access token", "properties": { @@ -6330,6 +6311,14 @@ "type": "integer", "x-go-name": "SourceID" }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/UserTypeString" + } + ], + "description": "The user type" + }, "visibility": { "allOf": [ { @@ -10742,15 +10731,12 @@ "x-go-name": "StarredRepos" }, "type": { - "description": "the account type", - "enum": [ - "User", - "Organization", - "Bot" + "allOf": [ + { + "$ref": "#/components/schemas/UserTypeString" + } ], - "type": "string", - "x-go-enum-desc": "User UserTypeStringUser\nOrganization UserTypeStringOrganization\nBot UserTypeStringBot", - "x-go-name": "Type" + "description": "the user type" }, "visibility": { "allOf": [ @@ -10908,6 +10894,14 @@ "type": "object", "x-go-package": "gitea.dev/modules/structs" }, + "UserTypeString": { + "enum": [ + "User", + "Organization", + "Bot" + ], + "type": "string" + }, "VisibilityString": { "enum": [ "public", @@ -12291,51 +12285,6 @@ ] } }, - "/admin/users/{username}/convert-type": { - "post": { - "operationId": "adminConvertUserType", - "parameters": [ - { - "description": "username of the user to convert", - "in": "path", - "name": "username", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConvertUserTypeOption" - } - } - }, - "required": true, - "x-originalParamName": "body" - }, - "responses": { - "204": { - "$ref": "#/components/responses/empty" - }, - "400": { - "$ref": "#/components/responses/error" - }, - "403": { - "$ref": "#/components/responses/forbidden" - }, - "404": { - "$ref": "#/components/responses/notFound" - } - }, - "summary": "Convert an account between the user and bot types", - "tags": [ - "admin" - ] - } - }, "/admin/users/{username}/keys": { "post": { "operationId": "adminCreatePublicKey", diff --git a/templates/swagger/v1-swagger.generated.json b/templates/swagger/v1-swagger.generated.json index 7d0148e209e..38fc9172f4a 100644 --- a/templates/swagger/v1-swagger.generated.json +++ b/templates/swagger/v1-swagger.generated.json @@ -1144,52 +1144,6 @@ } } }, - "/admin/users/{username}/convert-type": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Convert an account between the user and bot types", - "operationId": "adminConvertUserType", - "parameters": [ - { - "type": "string", - "description": "username of the user to convert", - "name": "username", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/ConvertUserTypeOption" - } - } - ], - "responses": { - "204": { - "$ref": "#/responses/empty" - }, - "400": { - "$ref": "#/responses/error" - }, - "403": { - "$ref": "#/responses/forbidden" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - } - }, "/admin/users/{username}/keys": { "post": { "consumes": [ @@ -26790,25 +26744,6 @@ }, "x-go-package": "gitea.dev/modules/structs" }, - "ConvertUserTypeOption": { - "description": "ConvertUserTypeOption options when converting a user between individual and bot", - "type": "object", - "required": [ - "user_type" - ], - "properties": { - "user_type": { - "description": "The target user type", - "type": "string", - "enum": [ - "User", - "Bot" - ], - "x-go-name": "UserType" - } - }, - "x-go-package": "gitea.dev/modules/structs" - }, "CreateAccessTokenOption": { "description": "CreateAccessTokenOption options when create access token", "type": "object", @@ -29457,6 +29392,17 @@ "format": "int64", "x-go-name": "SourceID" }, + "type": { + "description": "The user type", + "type": "string", + "enum": [ + "User", + "Organization", + "Bot" + ], + "x-go-enum-desc": "User UserTypeStringUser\nOrganization UserTypeStringOrganization\nBot UserTypeStringBot", + "x-go-name": "Type" + }, "visibility": { "description": "User visibility level: public, limited, or private", "type": "string", @@ -33820,7 +33766,7 @@ "x-go-name": "StarredRepos" }, "type": { - "description": "the account type", + "description": "the user type", "type": "string", "enum": [ "User", diff --git a/tests/integration/admin_user_test.go b/tests/integration/admin_user_test.go index f3638aab4e0..67f5bbdf56b 100644 --- a/tests/integration/admin_user_test.go +++ b/tests/integration/admin_user_test.go @@ -259,7 +259,9 @@ func TestAdminBotUser(t *testing.T) { }), http.StatusSeeOther) } - MakeRequest(t, NewRequestWithJSON(t, "POST", "/api/v1/admin/users/user4/convert-type", map[string]string{"user_type": "Bot"}).AddBasicAuth("user1"), http.StatusNoContent) + MakeRequest(t, NewRequestWithJSON(t, "PATCH", "/api/v1/admin/users/org3", map[string]string{"type": "Organization"}).AddBasicAuth("user1"), http.StatusOK) + MakeRequest(t, NewRequestWithJSON(t, "PATCH", "/api/v1/admin/users/user4", map[string]string{"type": "Bot", "password": "Bot-Password-1234"}).AddBasicAuth("user1"), http.StatusBadRequest) + MakeRequest(t, NewRequestWithJSON(t, "PATCH", "/api/v1/admin/users/user4", map[string]string{"type": "Bot"}).AddBasicAuth("user1"), http.StatusOK) user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) assert.True(t, user4.IsTypeBot()) resp := MakeRequest(t, NewRequest(t, "GET", "/api/v1/users/user4"), http.StatusOK)