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}`.
This commit is contained in:
silverwind
2026-09-18 17:11:56 +02:00
committed by GitHub
parent 3bec08f998
commit 85eaf5c71c
12 changed files with 57 additions and 203 deletions
+2 -9
View File
@@ -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)"`
}
+1 -1
View File
@@ -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"`
+1 -1
View File
@@ -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).
-1
View File
@@ -3083,7 +3083,6 @@
"admin.users.impersonate_stop": "Stop impersonating",
"admin.users.impersonating_notice": "You are impersonating <strong>%s</strong>. 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.",
+15 -43
View File
@@ -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)
}
-1
View File
@@ -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)
-3
View File
@@ -59,9 +59,6 @@ type swaggerParameterBodies struct {
// in:body
RenameUserOption api.RenameUserOption
// in:body
ConvertUserTypeOption api.ConvertUserTypeOption
// in:body
CreateLabelOption api.CreateLabelOption
// in:body
-3
View File
@@ -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)
+2 -2
View File
@@ -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),
+21 -72
View File
@@ -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",
+12 -66
View File
@@ -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",
+3 -1
View File
@@ -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)