From 3bec08f998eb0d4a90615a4f580715a7364f7d61 Mon Sep 17 00:00:00 2001 From: "Joe (Agent) Stump" Date: Fri, 18 Sep 2026 14:43:36 +0200 Subject: [PATCH] feat: manage bot accounts from the admin UI, API and CLI (#38966) Adds first-class bot accounts (`UserTypeBot`): local, password-less users for automation that authenticate only with access tokens. 1. Admin UI: create bots, filter users by type, manage a bot's access tokens, convert between user and bot 2. API: `POST /admin/users/{username}/convert-type`, and user objects gain a GitHub-compatible `type` (`User`, `Organization`, `Bot`) 3. CLI: `gitea admin user change-type`, `--user-type` accepts `User` or `Bot` case-insensitively 4. Converting keeps the password, 2FA, OAuth2 grants and access tokens, and since sign-in rejects bots, converting back restores the account. Only local, non-admin accounts can be converted, and conversions are audited 5. Session, reverse proxy, SSPI, external source and password reset sign-in reject non-individual users, so a bot never gets an interactive session 6. Bots receive no notifications or emails Co-authored-by: Nicolas Co-authored-by: joestump Co-authored-by: Joe Stump Co-authored-by: silverwind Co-authored-by: Lunny Xiao --- cmd/admin_user.go | 17 +++ cmd/admin_user_change_type.go | 61 ++++++++ cmd/admin_user_create.go | 20 ++- cmd/admin_user_create_test.go | 11 +- models/activities/notification.go | 10 +- models/activities/notification_list.go | 3 + models/activities/notification_test.go | 22 +++ models/audit/action.go | 1 + models/shared/types/ownertype.go | 2 +- models/user/error.go | 6 + models/user/search.go | 14 -- models/user/user.go | 17 ++- modules/structs/admin_user.go | 9 ++ modules/structs/user.go | 2 + modules/structs/user_type.go | 17 +++ modules/templates/util_misc.go | 9 ++ modules/templates/util_render.go | 11 +- options/locale/locale_en-US.json | 10 +- routers/api/v1/admin/user.go | 47 ++++++- routers/api/v1/api.go | 1 + routers/api/v1/swagger/options.go | 3 + routers/web/admin/orgs.go | 5 +- routers/web/admin/users.go | 108 ++++++++++++--- routers/web/auth/password.go | 3 + routers/web/user/setting/applications.go | 98 ++++++++----- routers/web/web.go | 4 +- services/auth/basic.go | 3 + services/auth/oauth2.go | 10 +- services/auth/reverseproxy.go | 6 +- services/auth/reverseproxy_test.go | 17 +++ services/auth/session.go | 6 + services/auth/session_test.go | 38 +++++ services/auth/signin.go | 8 +- services/auth/signin_test.go | 47 +++++++ services/auth/sspi.go | 3 + services/convert/user.go | 25 ++++ services/forms/admin.go | 12 +- services/mailer/mail_issue.go | 6 +- services/mailer/mail_repo.go | 8 +- services/mailer/mail_test.go | 22 +++ services/mailer/mail_user.go | 3 +- services/user/update.go | 30 +++- services/user/update_test.go | 40 ++++++ templates/admin/user/edit.tmpl | 73 ++++++---- templates/admin/user/list.tmpl | 21 ++- templates/admin/user/new.tmpl | 39 ++++-- templates/admin/user/view.tmpl | 7 +- templates/admin/user/view_details.tmpl | 3 - templates/admin/user/view_orgs.tmpl | 2 +- templates/repo/commit_page.tmpl | 2 +- .../repo/issue/sidebar/assignee_list.tmpl | 6 +- .../repo/issue/sidebar/reviewer_list.tmpl | 2 +- templates/repo/issue/view_title.tmpl | 4 +- templates/repo/search_name.tmpl | 2 +- templates/repo/user_cards.tmpl | 4 +- templates/shared/user/access_tokens.tmpl | 125 +++++++++++++++++ templates/shared/user/authorlink.tmpl | 2 +- templates/shared/user/name.tmpl | 2 +- templates/shared/user/namelink.tmpl | 2 +- templates/shared/user/profile_big_avatar.tmpl | 2 +- templates/shared/user/user_type_label.tmpl | 1 + templates/swagger/v1-openapi3.generated.json | 75 ++++++++++ templates/swagger/v1-swagger.generated.json | 76 ++++++++++ templates/user/settings/applications.tmpl | 111 +-------------- templates/user/settings/organization.tmpl | 2 +- tests/e2e/admin.test.ts | 26 ++++ tests/integration/admin_audit_test.go | 2 +- tests/integration/admin_user_test.go | 130 ++++++++++++++++++ tests/integration/integration_test.go | 9 +- web_src/js/features/admin/common.ts | 49 +++---- web_src/js/features/admin/users.ts | 2 +- 71 files changed, 1246 insertions(+), 330 deletions(-) create mode 100644 cmd/admin_user_change_type.go create mode 100644 modules/structs/user_type.go create mode 100644 services/auth/session_test.go create mode 100644 services/auth/signin_test.go create mode 100644 templates/shared/user/access_tokens.tmpl create mode 100644 templates/shared/user/user_type_label.tmpl create mode 100644 tests/e2e/admin.test.ts diff --git a/cmd/admin_user.go b/cmd/admin_user.go index 471154b701a..baf8bf2727b 100644 --- a/cmd/admin_user.go +++ b/cmd/admin_user.go @@ -5,10 +5,26 @@ package cmd import ( "context" + "strings" + + user_model "gitea.dev/models/user" + "gitea.dev/modules/util" "github.com/urfave/cli/v3" ) +// parseUserTypeFlag parses "--user-type", keeping the lowercase "individual" and "bot" the flag shipped with +func parseUserTypeFlag(s string) (user_model.UserType, error) { + switch strings.ToLower(s) { + case "user", "individual": + return user_model.UserTypeIndividual, nil + case "bot": + return user_model.UserTypeBot, nil + default: + return 0, util.NewInvalidArgumentErrorf("invalid user type %q (expected User, Bot)", s) + } +} + func newUserCommand() *cli.Command { return &cli.Command{ Name: "user", @@ -24,6 +40,7 @@ func newUserCommand() *cli.Command { newUserGenerateAccessTokenCommand(), microcmdUserMustChangePassword(), microcmdUserDisableTwoFactor(), + microcmdUserChangeType(), }, } } diff --git a/cmd/admin_user_change_type.go b/cmd/admin_user_change_type.go new file mode 100644 index 00000000000..16d9b3b7386 --- /dev/null +++ b/cmd/admin_user_change_type.go @@ -0,0 +1,61 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + + user_model "gitea.dev/models/user" + "gitea.dev/modules/optional" + "gitea.dev/modules/setting" + user_service "gitea.dev/services/user" + + "github.com/urfave/cli/v3" +) + +func microcmdUserChangeType() *cli.Command { + return &cli.Command{ + Name: "change-type", + Usage: "Convert an account between the user and bot types", + Action: runChangeUserType, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Usage: "The user to convert", + Required: true, + }, + &cli.StringFlag{ + Name: "user-type", + Usage: "New user type: User or Bot", + Required: true, + }, + }, + } +} + +func runChangeUserType(ctx context.Context, c *cli.Command) error { + targetType, err := parseUserTypeFlag(c.String("user-type")) + if err != nil { + return err + } + + if !setting.IsInTesting { + if err := initDB(ctx); err != nil { + return err + } + } + + user, err := user_model.GetUserByName(ctx, c.String("username")) + if err != nil { + return err + } + + if err := user_service.UpdateUser(ctx, user, &user_service.UpdateOptions{UserType: optional.Some(targetType)}); err != nil { + return err + } + + cprintf(c, "%s's type has been successfully changed to %s!\n", user.Name, targetType.DisplayName()) + return nil +} diff --git a/cmd/admin_user_create.go b/cmd/admin_user_create.go index 2db926d27af..ef1eca7ff40 100644 --- a/cmd/admin_user_create.go +++ b/cmd/admin_user_create.go @@ -44,8 +44,8 @@ func microcmdUserCreate() *cli.Command { Flags: []cli.Flag{ &cli.StringFlag{ Name: "user-type", - Usage: "Set user's type: individual or bot", - Value: "individual", + Usage: "Set user's type: User or Bot", + Value: "User", }, &cli.StringFlag{ Name: "password", @@ -105,20 +105,16 @@ func runCreateUser(ctx context.Context, c *cli.Command) error { // duplicate setting loading should be safe at the moment, but it should be refactored & improved in the future. setting.LoadSettings() - userTypes := map[string]user_model.UserType{ - "individual": user_model.UserTypeIndividual, - "bot": user_model.UserTypeBot, - } - userType, ok := userTypes[c.String("user-type")] - if !ok { - return fmt.Errorf("invalid user type: %s", c.String("user-type")) + userType, err := parseUserTypeFlag(c.String("user-type")) + if err != nil { + return err } if userType != user_model.UserTypeIndividual { - // Some other commands like "change-password" also only support individual users. + // Some other commands like "change-password" also only support regular user accounts. // It needs to clarify the "password" behavior for bot users in the future. // At the moment, we do not allow setting password for bot users. if c.IsSet("password") || c.IsSet("random-password") { - return errors.New("password can only be set for individual users") + return errors.New("password can only be set for user accounts") } } @@ -162,7 +158,7 @@ func runCreateUser(ctx context.Context, c *cli.Command) error { mustChangePassword := userType == user_model.UserTypeIndividual if c.IsSet("must-change-password") { if userType != user_model.UserTypeIndividual { - return errors.New("must-change-password flag can only be set for individual users") + return errors.New("must-change-password flag can only be set for user accounts") } // if the flag is set, use the value provided by the user mustChangePassword = c.Bool("must-change-password") diff --git a/cmd/admin_user_create_test.go b/cmd/admin_user_create_test.go index ece2e8869d5..66b93683d23 100644 --- a/cmd/admin_user_create_test.go +++ b/cmd/admin_user_create_test.go @@ -56,14 +56,21 @@ func TestAdminUserCreate(t *testing.T) { t.Run("UserType", func(t *testing.T) { reset() assert.ErrorContains(t, createUser("u", "--user-type", "invalid"), "invalid user type") - assert.ErrorContains(t, createUser("u", "--user-type", "bot", "--password", "123"), "can only be set for individual users") - assert.ErrorContains(t, createUser("u", "--user-type", "bot", "--must-change-password"), "can only be set for individual users") + assert.ErrorContains(t, createUser("u", "--user-type", "bot", "--password", "123"), "can only be set for user accounts") + assert.ErrorContains(t, createUser("u", "--user-type", "bot", "--must-change-password"), "can only be set for user accounts") + assert.ErrorContains(t, createUser("u", "--user-type", "bot", "--admin"), "bot user can not be a site administrator") assert.NoError(t, createUser("u", "--user-type", "bot")) u := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "u"}) assert.Equal(t, user_model.UserTypeBot, u.Type) assert.Empty(t, u.Passwd) assert.False(t, u.MustChangePassword, "bot users should not be forced to change password") + + changeType := func(userType string) error { + return microcmdUserChangeType().Run(t.Context(), []string{"change-type", "--username", "u", "--user-type", userType}) + } + assert.NoError(t, changeType("User")) + assert.True(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "u"}).IsIndividual()) }) t.Run("AccessToken", func(t *testing.T) { diff --git a/models/activities/notification.go b/models/activities/notification.go index 482bce8ad79..8f6f6f4818b 100644 --- a/models/activities/notification.go +++ b/models/activities/notification.go @@ -125,7 +125,10 @@ func CreateRepoTransferNotification(ctx context.Context, doer, newOwner *user_mo if err != nil || len(users) == 0 { return err } - for i := range users { + for i, user := range users { + if user.IsTypeBot() { + continue + } notify = append(notify, &Notification{ UserID: i, RepoID: repo.ID, @@ -134,7 +137,7 @@ func CreateRepoTransferNotification(ctx context.Context, doer, newOwner *user_mo Source: NotificationSourceRepository, }) } - } else { + } else if !newOwner.IsTypeBot() { notify = []*Notification{{ UserID: newOwner.ID, RepoID: repo.ID, @@ -144,6 +147,9 @@ func CreateRepoTransferNotification(ctx context.Context, doer, newOwner *user_mo }} } + if len(notify) == 0 { + return nil + } return db.Insert(ctx, notify) }) } diff --git a/models/activities/notification_list.go b/models/activities/notification_list.go index e344b7f21de..9353ff5d5f6 100644 --- a/models/activities/notification_list.go +++ b/models/activities/notification_list.go @@ -168,6 +168,9 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n return nil, err } + if user.IsTypeBot() { + continue + } if issue.IsPull && !access_model.CheckRepoUnitUser(ctx, issue.Repo, user, unit.TypePullRequests) { continue } diff --git a/models/activities/notification_test.go b/models/activities/notification_test.go index 1438afb3cce..74838d8671f 100644 --- a/models/activities/notification_test.go +++ b/models/activities/notification_test.go @@ -66,6 +66,28 @@ func TestCreateOrUpdateIssueNotificationsIgnored(t *testing.T) { assert.Empty(t, notified) } +func TestNotificationsSkipBots(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + for _, id := range []int64{4, 28} { + assert.NoError(t, user_model.UpdateUserCols(t.Context(), &user_model.User{ID: id, Type: user_model.UserTypeBot}, "type")) + } + + notifiedIDs, err := activities_model.CreateOrUpdateIssueNotifications(t.Context(), 1, 0, 2, 0) + assert.NoError(t, err) + assert.Contains(t, notifiedIDs, int64(1)) + assert.NotContains(t, notifiedIDs, int64(4)) + + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + assert.NoError(t, activities_model.CreateRepoTransferNotification(t.Context(), doer, org, unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}))) + unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 2, RepoID: 3, Source: activities_model.NotificationSourceRepository}) + unittest.AssertNotExistsBean(t, &activities_model.Notification{UserID: 28, RepoID: 3, Source: activities_model.NotificationSourceRepository}) + + bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + assert.NoError(t, activities_model.CreateRepoTransferNotification(t.Context(), doer, bot, unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}))) + unittest.AssertNotExistsBean(t, &activities_model.Notification{UserID: 4, RepoID: 1, Source: activities_model.NotificationSourceRepository}) +} + func TestNotificationsForUser(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) diff --git a/models/audit/action.go b/models/audit/action.go index 19858409971..b4d8d2f61f1 100644 --- a/models/audit/action.go +++ b/models/audit/action.go @@ -71,6 +71,7 @@ var ( UserActive = define("user:status:active", "Changed activation status of user {scope} to {active}.") UserRestricted = define("user:status:restricted", "Changed restricted status of user {scope} to {restricted}.") UserAdmin = define("user:status:admin", "Changed admin status of user {scope} to {admin}.") + UserType = define("user:type:update", "Changed type of user {scope} to {user_type}.") UserName = define("user:name:update", "Changed user name from {previous_name} to {scope}.") UserPassword = define("user:password", "Changed password of user {scope}.") UserPasswordResetRequest = define("user:password:resetrequest", "Requested password reset for user {scope}.") diff --git a/models/shared/types/ownertype.go b/models/shared/types/ownertype.go index 98a59c7008f..044ce94a984 100644 --- a/models/shared/types/ownertype.go +++ b/models/shared/types/ownertype.go @@ -19,7 +19,7 @@ func (o OwnerType) LocaleString(locale translation.Locale) string { case OwnerTypeSystemGlobal: return locale.TrString("concept_system_global") case OwnerTypeIndividual: - return locale.TrString("concept_user_individual") + return locale.TrString("concept_user_user") case OwnerTypeRepository: return locale.TrString("concept_code_repository") case OwnerTypeOrganization: diff --git a/models/user/error.go b/models/user/error.go index 28ea4f21c1c..a0dc1f9172b 100644 --- a/models/user/error.go +++ b/models/user/error.go @@ -86,3 +86,9 @@ func IsErrUserIsNotLocal(err error) bool { _, ok := err.(ErrUserIsNotLocal) return ok } + +var ( + ErrBotCanNotBeAdmin = util.NewInvalidArgumentErrorf("bot user can not be a site administrator") + ErrBotMustBeLocal = util.NewInvalidArgumentErrorf("bot user must use local authentication") + ErrUserTypeCanNotConvert = util.NewInvalidArgumentErrorf("user type can not be converted") +) diff --git a/models/user/search.go b/models/user/search.go index 1af5ab8274c..7f835dc3ff7 100644 --- a/models/user/search.go +++ b/models/user/search.go @@ -6,7 +6,6 @@ package user import ( "context" "fmt" - "slices" "strings" "gitea.dev/models/db" @@ -55,7 +54,6 @@ type SearchUserOptions struct { IsRestricted optional.Option[bool] IsTwoFactorEnabled optional.Option[bool] IsProhibitLogin optional.Option[bool] - IncludeReserved bool } func (opts *SearchUserOptions) ToOrders() string { @@ -71,18 +69,6 @@ func (opts *SearchUserOptions) ApplyPublicOnly(publicOnly bool) { func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) db.Session { var cond builder.Cond cond = builder.In("type", opts.Types) - if opts.IncludeReserved { - switch { - case slices.Contains(opts.Types, UserTypeIndividual): - cond = cond.Or(builder.Eq{"type": UserTypeUserReserved}).Or( - builder.Eq{"type": UserTypeBot}, - ).Or( - builder.Eq{"type": UserTypeRemoteUser}, - ) - case slices.Contains(opts.Types, UserTypeOrganization): - cond = cond.Or(builder.Eq{"type": UserTypeOrganizationReserved}) - } - } if len(opts.Keyword) > 0 { lowerKeyword := strings.ToLower(opts.Keyword) diff --git a/models/user/user.go b/models/user/user.go index c30be783a37..a9bb8e0f5b0 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -68,6 +68,18 @@ const ( UserTypeRemoteUser // 5 ) +// DisplayName returns the English name of the user type for logs and the CLI, the UI translates "concept_user_*" instead +func (t UserType) DisplayName() string { + switch t { + case UserTypeOrganization, UserTypeOrganizationReserved: + return "Organization" + case UserTypeBot: + return "Bot" + default: + return "User" + } +} + const ( // EmailNotificationsEnabled indicates that the user would like to receive all email notifications except your own EmailNotificationsEnabled = "enabled" @@ -907,7 +919,7 @@ func GetVerifyUser(ctx context.Context, code string) (user *User) { // use tail hex username query user hexStr := code[base.TimeLimitCodeLength:] if b, err := hex.DecodeString(hexStr); err == nil { - if user, err = GetUserByName(ctx, string(b)); user != nil { + if user, err = GetUserByName(ctx, string(b)); user != nil && user.IsIndividual() { return user } log.Error("user.getVerifyUser: %v", err) @@ -962,6 +974,9 @@ func ValidateUser(u *User, cols ...string) error { return fmt.Errorf("visibility Mode not allowed: %s", u.Visibility.String()) } } + if u.IsAdmin && u.IsTypeBot() { + return ErrBotCanNotBeAdmin + } return nil } diff --git a/modules/structs/admin_user.go b/modules/structs/admin_user.go index 9795e83b5db..8cc645fa2ae 100644 --- a/modules/structs/admin_user.go +++ b/modules/structs/admin_user.go @@ -77,3 +77,12 @@ type EditUserOption struct { // 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)"` +} diff --git a/modules/structs/user.go b/modules/structs/user.go index a14f5805ca5..4609e181207 100644 --- a/modules/structs/user.go +++ b/modules/structs/user.go @@ -17,6 +17,8 @@ type User struct { ID int64 `json:"id"` // login of the user, same as `username` UserName string `json:"login"` + // the account type + Type UserTypeString `json:"type"` // identifier of the user, provided by the external authenticator (if configured) LoginName string `json:"login_name"` // The ID of the user's Authentication Source diff --git a/modules/structs/user_type.go b/modules/structs/user_type.go new file mode 100644 index 00000000000..2c987427af7 --- /dev/null +++ b/modules/structs/user_type.go @@ -0,0 +1,17 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +// UserTypeString defines the account 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). +// swagger:enum UserTypeString +type UserTypeString string + +const ( + UserTypeStringUser UserTypeString = "User" + UserTypeStringOrganization UserTypeString = "Organization" + UserTypeStringBot UserTypeString = "Bot" +) diff --git a/modules/templates/util_misc.go b/modules/templates/util_misc.go index ce6c4681a07..f2c1380ca35 100644 --- a/modules/templates/util_misc.go +++ b/modules/templates/util_misc.go @@ -16,6 +16,7 @@ import ( repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" "gitea.dev/modules/git" + "gitea.dev/modules/htmlutil" "gitea.dev/modules/json" "gitea.dev/modules/log" "gitea.dev/modules/repository" @@ -168,6 +169,14 @@ func mirrorRemoteAddress(ctx context.Context, m *repo_model.Repository, remoteNa return ret } +// UserTypeLabel marks a bot account next to a name built in Go, the template equivalent is shared/user/user_type_label +func (ut *RenderUtils) UserTypeLabel(u *user_model.User) template.HTML { + if u == nil || !u.IsTypeBot() { + return "" + } + return htmlutil.HTMLFormat(` %s`, ut.locale().TrString("concept_user_bot")) +} + func filenameIsImage(filename string) bool { mimeType := mime.TypeByExtension(filepath.Ext(filename)) return strings.HasPrefix(mimeType, "image/") diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go index 82064a30975..e9bafc60eb1 100644 --- a/modules/templates/util_render.go +++ b/modules/templates/util_render.go @@ -411,10 +411,10 @@ func (ut *RenderUtils) AvatarStackWithNames(data *user_model.AvatarStackData) te // participantNameLink prefers (in order): commits-by-author search, `GetShortDisplayNameLinkHTML` (keeps alt-name tooltip), `mailto:`, bare name. func (ut *RenderUtils) participantNameLink(data *user_model.AvatarStackData, participant *user_model.CommitParticipant) template.HTML { if href := renderAvatarStackViewEmailLink(data, participant.GitIdentity.Email); href != "" { - return htmlutil.HTMLFormat(`%s`, href, participantName(participant)) + return htmlutil.HTMLFormat(`%s%s`, href, participantName(participant), ut.UserTypeLabel(participant.GiteaUser)) } if participant.GiteaUser != nil { - return participant.GiteaUser.GetShortDisplayNameLinkHTML() + return participant.GiteaUser.GetShortDisplayNameLinkHTML() + ut.UserTypeLabel(participant.GiteaUser) } if participant.GitIdentity.Email != "" { return htmlutil.HTMLFormat(`%s`, participant.GitIdentity.Email, participant.GitIdentity.Name) @@ -423,10 +423,9 @@ func (ut *RenderUtils) participantNameLink(data *user_model.AvatarStackData, par } func (ut *RenderUtils) participantPopupRow(data *user_model.AvatarStackData, participant *user_model.CommitParticipant) template.HTML { - avatar := ut.participantAvatar(participant) - name := participantName(participant) + avatar, name, label := ut.participantAvatar(participant), participantName(participant), ut.UserTypeLabel(participant.GiteaUser) if href := ut.participantHref(data, participant); href != "" { - return htmlutil.HTMLFormat(`%s%s`, href, avatar, name) + return htmlutil.HTMLFormat(`%s%s%s`, href, avatar, name, label) } - return htmlutil.HTMLFormat(`%s%s`, avatar, name) + return htmlutil.HTMLFormat(`%s%s%s`, avatar, name, label) } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 99c6b05c283..e5492de8170 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -129,9 +129,10 @@ "confirm_delete_artifact": "Are you sure you want to delete the artifact '%s'?", "archived": "Archived", "concept_system_global": "Global", - "concept_user_individual": "Individual", + "concept_user_user": "User", "concept_code_repository": "Repository", "concept_user_organization": "Organization", + "concept_user_bot": "Bot", "show_timestamps": "Show timestamps", "show_log_seconds": "Show seconds", "show_full_screen": "Show full screen", @@ -3069,7 +3070,6 @@ "admin.users.admin": "Admin", "admin.users.restricted": "Restricted", "admin.users.reserved": "Reserved", - "admin.users.bot": "Bot", "admin.users.remote": "Remote", "admin.users.2fa": "2FA", "admin.users.repos": "Repos", @@ -3082,6 +3082,12 @@ "admin.users.impersonate": "Impersonate", "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.", + "admin.users.impersonate_bot_not_allowed": "Bot accounts are non-interactive and cannot be impersonated.", "admin.users.auth_source": "Authentication Source", "admin.users.local": "Local", "admin.users.auth_login_name": "Authentication Sign-In Name", diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index 9dc359500bb..c1c25685ec7 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -206,7 +206,7 @@ func EditUser(ctx *context.APIContext) { case errors.Is(err, password.ErrIsPwned), password.IsErrIsPwnedRequest(err): ctx.APIError(http.StatusBadRequest, err.Error()) default: - ctx.APIErrorInternal(err) + ctx.APIErrorAuto(err) } return } @@ -237,7 +237,7 @@ func EditUser(ctx *context.APIContext) { if user_model.IsErrDeleteLastAdminUser(err) { ctx.APIError(http.StatusBadRequest, err.Error()) } else { - ctx.APIErrorInternal(err) + ctx.APIErrorAuto(err) } return } @@ -552,3 +552,46 @@ 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 8953647a99a..f42116d6683 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1905,6 +1905,7 @@ 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 0522fcec680..6a1eaaede60 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -59,6 +59,9 @@ 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/orgs.go b/routers/web/admin/orgs.go index 02037af89fa..057ac27cb35 100644 --- a/routers/web/admin/orgs.go +++ b/routers/web/admin/orgs.go @@ -25,9 +25,8 @@ func Organizations(ctx *context.Context) { sortOrder := ctx.FormString("sort", UserSearchDefaultAdminSort) explore.RenderUserSearch(ctx, user_model.SearchUserOptions{ - Actor: ctx.Doer, - Types: []user_model.UserType{user_model.UserTypeOrganization}, - IncludeReserved: true, // administrator needs to list all accounts include reserved + Actor: ctx.Doer, + Types: []user_model.UserType{user_model.UserTypeOrganization, user_model.UserTypeOrganizationReserved}, ListOptions: db.ListOptions{ PageSize: setting.UI.Admin.OrgPagingNum, }, diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index 7d6f86975d9..a777eaa25c0 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -22,6 +22,7 @@ import ( "gitea.dev/modules/log" "gitea.dev/modules/optional" "gitea.dev/modules/setting" + api "gitea.dev/modules/structs" "gitea.dev/modules/templates" "gitea.dev/modules/util" "gitea.dev/modules/web" @@ -30,6 +31,7 @@ import ( "gitea.dev/services/audit" auth_service "gitea.dev/services/auth" "gitea.dev/services/context" + "gitea.dev/services/convert" "gitea.dev/services/forms" "gitea.dev/services/mailer" org_service "gitea.dev/services/org" @@ -60,6 +62,15 @@ func Users(ctx *context.Context) { } sortType := ctx.FormString("sort", UserSearchDefaultAdminSort) + + // unfiltered, an administrator needs to list every account kind + types := []user_model.UserType{user_model.UserTypeIndividual, user_model.UserTypeUserReserved, user_model.UserTypeBot, user_model.UserTypeRemoteUser} + userTypeFilter := api.UserTypeString(ctx.FormString("user_type")) + ctx.Data["UserTypeFilter"] = "" + if t, err := convert.UserTypeFromString(userTypeFilter); err == nil { + types, ctx.Data["UserTypeFilter"] = []user_model.UserType{t}, userTypeFilter + } + ctx.PageData["adminUserListSearchForm"] = map[string]any{ "StatusFilterMap": statusFilterMap, "SortType": sortType, @@ -67,7 +78,7 @@ func Users(ctx *context.Context) { explore.RenderUserSearch(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, - Types: []user_model.UserType{user_model.UserTypeIndividual}, + Types: types, ListOptions: db.ListOptions{ PageSize: setting.UI.Admin.UserPagingNum, }, @@ -77,7 +88,6 @@ func Users(ctx *context.Context) { IsRestricted: optional.ParseBool(statusFilterMap["is_restricted"]), IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]), IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]), - IncludeReserved: true, // administrator needs to list all accounts include reserved, bot, remote ones OrderBy: db.SearchOrderBy(sortType), }, tplUsers) } @@ -90,6 +100,7 @@ func NewUser(ctx *context.Context) { ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() ctx.Data["login_type"] = "0-0" + ctx.Data["user_type"] = api.UserTypeStringUser sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{ IsActive: optional.Some(true), @@ -140,7 +151,10 @@ func NewUserPost(ctx *context.Context) { Visibility: &form.Visibility, } - if len(form.LoginType) > 0 { + if form.UserType == api.UserTypeStringBot { + u.Type = user_model.UserTypeBot + u.Passwd = "" + } else if len(form.LoginType) > 0 { fields := strings.Split(form.LoginType, "-") if len(fields) == 2 { lType, _ := strconv.ParseInt(fields[0], 10, 0) @@ -149,7 +163,7 @@ func NewUserPost(ctx *context.Context) { u.LoginName = form.LoginName } } - if u.LoginType == auth.NoType || u.LoginType == auth.Plain { + if !u.IsTypeBot() && (u.LoginType == auth.NoType || u.LoginType == auth.Plain) { if len(form.Password) < setting.MinPasswordLength { ctx.Data["Err_Password"] = true ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplUserNew, &form) @@ -260,6 +274,7 @@ func prepareUserInfo(ctx *context.Context) *user_model.User { return nil } ctx.Data["TwoFactorEnabled"] = hasTOTP || hasWebAuthn + ctx.Data["CanConvertUserType"] = user_service.CheckConvertUserType(u) == nil return u } @@ -305,9 +320,42 @@ func ViewUser(ctx *context.Context) { return } + if u.IsTypeBot() { + ctx.Data["BotAccessTokens"] = user_setting.NewAccessTokensPanel(ctx, u, ctx.Link+"/access_tokens") + if ctx.Written() { + return + } + } + ctx.HTML(http.StatusOK, tplUserView) } +// getTargetBot loads the bot whose tokens an admin manages, other accounts manage their own +func getTargetBot(ctx *context.Context) *user_model.User { + u, err := user_model.GetUserByID(ctx, ctx.PathParamInt64("userid")) + if err != nil { + ctx.NotFoundOrServerError("GetUserByID", user_model.IsErrUserNotExist, err) + return nil + } + if !u.IsTypeBot() { + ctx.JSONError(ctx.Tr("admin.users.bot_token_only")) + return nil + } + return u +} + +func NewBotTokenPost(ctx *context.Context) { + if u := getTargetBot(ctx); u != nil { + user_setting.CreateAccessToken(ctx, u) + } +} + +func DeleteBotToken(ctx *context.Context) { + if u := getTargetBot(ctx); u != nil { + user_setting.DeleteAccessToken(ctx, u) + } +} + func editUserCommon(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.users.edit_account") ctx.Data["PageIsAdminUsers"] = true @@ -344,6 +392,8 @@ func EditUserPost(ctx *context.Context) { return } + userLink := setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")) + if form.UserName != "" { if err := user_service.RenameUser(ctx, u, form.UserName, ctx.Doer); err != nil { switch { @@ -369,9 +419,19 @@ func EditUserPost(ctx *context.Context) { } } - authOpts := &user_service.UpdateAuthOptions{ - Password: optional.FromNonDefault(form.Password), - LoginName: optional.Some(form.LoginName), + userType := u.Type + if formUserType, err := convert.UserTypeFromString(form.UserType); err == nil { + userType = formUserType + } + + authOpts := &user_service.UpdateAuthOptions{} + if !u.IsTypeBot() && userType != user_model.UserTypeBot { // the auth fields hidden for bots still submit their values + authOpts.Password = optional.FromNonDefault(form.Password) + authOpts.LoginName = optional.Some(form.LoginName) + if fields := strings.Split(form.LoginType, "-"); len(fields) == 2 { + authSource, _ := strconv.ParseInt(fields[1], 10, 64) + authOpts.LoginSource = optional.Some(authSource) + } } // skip self Prohibit Login @@ -381,13 +441,6 @@ func EditUserPost(ctx *context.Context) { authOpts.ProhibitLogin = optional.Some(form.ProhibitLogin) } - fields := strings.Split(form.LoginType, "-") - if len(fields) == 2 { - authSource, _ := strconv.ParseInt(fields[1], 10, 64) - - authOpts.LoginSource = optional.Some(authSource) - } - if err := user_service.UpdateAuth(ctx, u, authOpts); err != nil { switch { case errors.Is(err, password.ErrMinLength): @@ -402,6 +455,9 @@ func EditUserPost(ctx *context.Context) { case password.IsErrIsPwnedRequest(err): ctx.Data["Err_Password"] = true ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_pwned_err"), tplUserEdit, &form) + case errors.Is(err, util.ErrInvalidArgument): + ctx.Flash.Error(err.Error()) + ctx.Redirect(userLink) default: ctx.ServerError("UpdateUser", err) } @@ -440,16 +496,28 @@ func EditUserPost(ctx *context.Context) { IsRestricted: optional.Some(form.Restricted), Visibility: optional.Some(form.Visibility), Language: optional.Some(form.Language), + UserType: optional.Some(userType), } if err := user_service.UpdateUser(ctx, u, opts); err != nil { - if user_model.IsErrDeleteLastAdminUser(err) { + switch { + case user_model.IsErrDeleteLastAdminUser(err): ctx.RenderWithErrDeprecated(ctx.Tr("auth.last_admin"), tplUserEdit, &form) - } else { + 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) + default: ctx.ServerError("UpdateUser", err) } return } + log.Trace("Account profile updated by admin (%s): %s", ctx.Doer.Name, u.Name) if form.Reset2FA { @@ -460,7 +528,7 @@ func EditUserPost(ctx *context.Context) { } ctx.Flash.Success(ctx.Tr("admin.users.update_profile_success")) - ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid"))) + ctx.Redirect(userLink) } func ImpersonateUser(ctx *context.Context) { @@ -469,6 +537,12 @@ func ImpersonateUser(ctx *context.Context) { ctx.JSONError("unable to get user") return } + + if u.IsTypeBot() { + ctx.JSONError(ctx.Tr("admin.users.impersonate_bot_not_allowed")) + return + } + err = auth_service.ImpersonateUser(ctx.Session, u) if err != nil { ctx.ServerError("unable to impersonate user", err) diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go index e5bbf6a19cc..1f4cebdef30 100644 --- a/routers/web/auth/password.go +++ b/routers/web/auth/password.go @@ -62,6 +62,9 @@ func ForgotPasswdPost(ctx *context.Context) { ctx.Data["Email"] = email u, err := user_model.GetUserByEmail(ctx, email) + if err == nil && !u.IsIndividual() { + err = user_model.ErrUserNotExist{} + } if err != nil { if user_model.IsErrUserNotExist(err) { ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale) diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index fc75a99f561..5e4845c534b 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -11,10 +11,10 @@ import ( audit_model "gitea.dev/models/audit" auth_model "gitea.dev/models/auth" "gitea.dev/models/db" + user_model "gitea.dev/models/user" "gitea.dev/modules/setting" "gitea.dev/modules/templates" "gitea.dev/modules/util" - "gitea.dev/modules/web" "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" @@ -22,6 +22,7 @@ import ( const ( tplSettingsApplications templates.TplName = "user/settings/applications" + tplAccessTokens templates.TplName = "shared/user/access_tokens" ) // Applications render manage access token page @@ -34,11 +35,40 @@ func Applications(ctx *context.Context) { ctx.HTML(http.StatusOK, tplSettingsApplications) } -// ApplicationsPost response for add user's access token -func ApplicationsPost(ctx *context.Context) { - form := web.GetForm[*forms.NewAccessTokenForm](ctx) - ctx.Data["Title"] = ctx.Tr("settings_title") - ctx.Data["PageIsSettingsApplications"] = true +type AccessTokensPanel struct { + Tokens []*auth_model.AccessToken + ScopeCategories []string + ScopePublicOnly auth_model.AccessTokenScope + Link string + IsBot bool + NewTokenValue string +} + +func NewAccessTokensPanel(ctx *context.Context, owner *user_model.User, link string) *AccessTokensPanel { + tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: owner.ID}) + if err != nil { + ctx.ServerError("ListAccessTokens", err) + return nil + } + panel := &AccessTokensPanel{ + Tokens: tokens, + ScopeCategories: auth_model.GetAccessTokenCategories(), + ScopePublicOnly: auth_model.AccessTokenScopePublicOnly, + Link: link, + IsBot: owner.IsTypeBot(), + } + if !owner.IsAdmin { + panel.ScopeCategories = util.SliceRemoveAll(panel.ScopeCategories, "admin") + } + return panel +} + +// CreateAccessToken handles the panel's create form, which posts to the panel link +func CreateAccessToken(ctx *context.Context, owner *user_model.User) { + form := context.GetFetchActionForm[*forms.NewAccessTokenForm](ctx) + if form == nil { + return + } _ = ctx.Req.ParseForm() var scopeNames []string @@ -55,17 +85,12 @@ func ApplicationsPost(ctx *context.Context) { return } if !scope.HasPermissionScope() { - ctx.Flash.Error(ctx.Tr("settings.at_least_one_permission"), true) - } - - if ctx.HasError() { - loadApplicationsData(ctx) - ctx.HTML(http.StatusOK, tplSettingsApplications) + ctx.JSONError(ctx.Tr("settings.at_least_one_permission")) return } t := &auth_model.AccessToken{ - UID: ctx.Doer.ID, + UID: owner.ID, Name: form.Name, Scope: scope, } @@ -76,8 +101,7 @@ func ApplicationsPost(ctx *context.Context) { return } if exist { - ctx.Flash.Error(ctx.Tr("settings.generate_token_name_duplicate", t.Name)) - ctx.Redirect(setting.AppSubURL + "/user/settings/applications") + ctx.JSONErrorWithField(ctx.Tr("settings.generate_token_name_duplicate", t.Name), "name") return } @@ -106,28 +130,41 @@ func ApplicationsPost(ctx *context.Context) { return } - audit.Record(ctx, audit_model.UserAccessTokenAdd, ctx.Doer, "token", t.Name, "token_scope", t.Scope) + audit.Record(ctx, audit_model.UserAccessTokenAdd, owner, "token", t.Name, "token_scope", t.Scope) - ctx.Flash.Success(ctx.Tr("settings.generate_token_success")) - ctx.Flash.Info(t.Token) + panel := NewAccessTokensPanel(ctx, owner, ctx.Link) + if ctx.Written() { + return + } + panel.NewTokenValue = t.Token + if err := ctx.Render.HTML(ctx.Resp, http.StatusOK, tplAccessTokens, panel, ctx.TemplateContext); err != nil { + ctx.ServerError("Render", err) + } +} - ctx.Redirect(setting.AppSubURL + "/user/settings/applications") +// ApplicationsPost response for add user's access token +func ApplicationsPost(ctx *context.Context) { + CreateAccessToken(ctx, ctx.Doer) } // DeleteApplication response for delete user access token func DeleteApplication(ctx *context.Context) { - t, err := auth_model.GetAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID) + DeleteAccessToken(ctx, ctx.Doer) +} + +func DeleteAccessToken(ctx *context.Context, owner *user_model.User) { + t, err := auth_model.GetAccessTokenByID(ctx, ctx.FormInt64("id"), owner.ID) if err != nil { ctx.Flash.Error("GetAccessTokenByID: " + err.Error()) - } else if err := auth_model.DeleteAccessTokenByID(ctx, t.ID, ctx.Doer.ID); err != nil { + } else if err := auth_model.DeleteAccessTokenByID(ctx, t.ID, owner.ID); err != nil { ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error()) } else { - audit.Record(ctx, audit_model.UserAccessTokenRemove, ctx.Doer, "token", t.Name) + audit.Record(ctx, audit_model.UserAccessTokenRemove, owner, "token", t.Name) ctx.Flash.Success(ctx.Tr("settings.delete_token_success")) } - ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications") + ctx.JSONRedirect("") } // RegenerateAccessToken response for regenerating a user's access token @@ -143,23 +180,14 @@ func RegenerateAccessToken(ctx *context.Context) { } func loadApplicationsData(ctx *context.Context) { - ctx.Data["AccessTokenScopePublicOnly"] = auth_model.AccessTokenScopePublicOnly - tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID}) - if err != nil { - ctx.ServerError("ListAccessTokens", err) + ctx.Data["AccessTokens"] = NewAccessTokensPanel(ctx, ctx.Doer, ctx.Link) + if ctx.Written() { return } - ctx.Data["Tokens"] = tokens ctx.Data["EnableOAuth2"] = setting.OAuth2.Enabled - // Handle specific ordered token categories for admin or non-admin users - tokenCategoryNames := auth_model.GetAccessTokenCategories() - if !ctx.Doer.IsAdmin { - tokenCategoryNames = util.SliceRemoveAll(tokenCategoryNames, "admin") - } - ctx.Data["TokenCategories"] = tokenCategoryNames - if setting.OAuth2.Enabled { + var err error ctx.Data["Applications"], err = db.Find[auth_model.OAuth2Application](ctx, auth_model.FindOAuth2ApplicationsOptions{ OwnerID: ctx.Doer.ID, }) diff --git a/routers/web/web.go b/routers/web/web.go index a3934d225f3..a3afe52e0c0 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -695,7 +695,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { // access token applications m.Combo("").Get(user_setting.Applications). - Post(web.Bind[*forms.NewAccessTokenForm](), user_setting.ApplicationsPost) + Post(user_setting.ApplicationsPost) m.Post("/delete", user_setting.DeleteApplication) m.Post("/regenerate", user_setting.RegenerateAccessToken) }) @@ -824,6 +824,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/{userid}/delete", admin.DeleteUser) m.Post("/{userid}/avatar", web.Bind[*forms.AvatarForm](), admin.AvatarPost) m.Post("/{userid}/avatar/delete", admin.DeleteAvatar) + m.Post("/{userid}/access_tokens", admin.NewBotTokenPost) + m.Post("/{userid}/access_tokens/delete", admin.DeleteBotToken) m.Post("/{userid}/orgs/{org_id}/remove", admin.RemoveUserFromOrg) m.Post("/{userid}/orgs/remove-all", admin.RemoveUserFromAllOrgs) }) diff --git a/services/auth/basic.go b/services/auth/basic.go index cca8b85a44f..feec2e57b51 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -82,6 +82,9 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store log.Error("GetUserByID: %v", err) return nil, err } + if !u.IsIndividual() { + return nil, nil //nolint:nilnil // the auth method is not applicable + } store.GetData()["LoginMethod"] = OAuth2TokenMethodName store.GetData()["ApiTokenScope"] = accessTokenScope diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index 207e10ae2c4..2fa3ba2a340 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -120,11 +120,13 @@ func (o *OAuth2) userFromToken(ctx context.Context, tokenSHA string, store DataS // Otherwise, check if this is an OAuth access token accessTokenScope, uid, grantID := GetOAuthAccessTokenScopeAndUserID(ctx, tokenSHA) - if uid != 0 { - store.GetData()["ApiTokenScope"] = accessTokenScope - setAuthCredential(store, credentialOAuth2Grant, grantID) + user, err := user_model.GetUserByID(ctx, uid) + if err != nil || !user.IsIndividual() { + return nil, err } - return user_model.GetUserByID(ctx, uid) + store.GetData()["ApiTokenScope"] = accessTokenScope + setAuthCredential(store, credentialOAuth2Grant, grantID) + return user, nil } t, err := auth_model.GetAccessTokenBySHA(ctx, tokenSHA) if err != nil { diff --git a/services/auth/reverseproxy.go b/services/auth/reverseproxy.go index 1cf3142285f..7d3e7edd8d9 100644 --- a/services/auth/reverseproxy.go +++ b/services/auth/reverseproxy.go @@ -114,9 +114,9 @@ func (r *ReverseProxy) Verify(req *http.Request, w http.ResponseWriter, store Da } if user == nil { user = r.getUserFromAuthEmail(req) - if user == nil { - return nil, nil //nolint:nilnil // the auth method is not applicable - } + } + if user == nil || !user.IsIndividual() { + return nil, nil //nolint:nilnil // the auth method is not applicable } if r.CreateSession && sess != nil { diff --git a/services/auth/reverseproxy_test.go b/services/auth/reverseproxy_test.go index 0602d295b37..0f9d7f2746f 100644 --- a/services/auth/reverseproxy_test.go +++ b/services/auth/reverseproxy_test.go @@ -4,6 +4,7 @@ package auth import ( + "net/http" "testing" "gitea.dev/models/unittest" @@ -17,6 +18,22 @@ import ( "github.com/stretchr/testify/require" ) +func TestReverseProxyIgnoresBot(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + defer test.MockVariableValue(&setting.ReverseProxyAuthUser, "X-WEBAUTH-USER")() + defer test.MockVariableValue(&setting.ReverseProxyAuthEmail, "X-WEBAUTH-EMAIL")() + defer test.MockVariableValue(&setting.Service.EnableReverseProxyEmail, true)() + require.NoError(t, user_model.UpdateUserCols(t.Context(), &user_model.User{ID: 2, Type: user_model.UserTypeBot}, "type")) + + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + req.Header.Set(setting.ReverseProxyAuthUser, "user2") + req.Header.Set(setting.ReverseProxyAuthEmail, "user2@example.com") + user, err := (&ReverseProxy{}).Verify(req, nil, nil, nil) + require.NoError(t, err) + assert.Nil(t, user) +} + func TestReverseProxyLastLogin(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) defer test.MockVariableValue(&setting.ReverseProxyAuthUser, "X-WEBAUTH-USER")() diff --git a/services/auth/session.go b/services/auth/session.go index 1a863d88f8c..8cb83dfa9d1 100644 --- a/services/auth/session.go +++ b/services/auth/session.go @@ -50,6 +50,12 @@ func (s *Session) Verify(req *http.Request, w http.ResponseWriter, store DataSto return nil, nil //nolint:nilnil // the auth method is not applicable } + // sessions can't be enumerated per user, so one opened before a conversion to bot is rejected here + if !user.IsIndividual() { + log.Trace("Session Authorization: user %-v is not an individual, ignoring the session", user) + return nil, nil //nolint:nilnil // the auth method is not applicable + } + log.Trace("Session Authorization: Logged in user %-v", user) return user, nil } diff --git a/services/auth/session_test.go b/services/auth/session_test.go new file mode 100644 index 00000000000..27db1d505fa --- /dev/null +++ b/services/auth/session_test.go @@ -0,0 +1,38 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "net/http" + "testing" + + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + "gitea.dev/modules/session" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSessionVerify(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + + sess := session.NewMockMemStore("dummy-sid") + method := &Session{} + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + require.NoError(t, sess.Set(session.KeyUID, user.ID)) + u, err := method.Verify(req, nil, nil, sess) + assert.NoError(t, err) + require.NotNil(t, u) + assert.Equal(t, user.ID, u.ID) + + require.NoError(t, user_model.UpdateUserCols(t.Context(), &user_model.User{ID: user.ID, Type: user_model.UserTypeBot}, "type")) + u, err = method.Verify(req, nil, nil, sess) + assert.NoError(t, err) + assert.Nil(t, u) +} diff --git a/services/auth/signin.go b/services/auth/signin.go index 516d5463bc4..9264e202169 100644 --- a/services/auth/signin.go +++ b/services/auth/signin.go @@ -122,10 +122,14 @@ func UserSignIn(ctx context.Context, username, password string) (*user_model.Use authUser, err := authenticator.Authenticate(ctx, nil, username, password) if err == nil { - if !authUser.ProhibitLogin { + switch { + case !authUser.IsIndividual(): + err = user_model.ErrUserNotExist{Name: username} + case authUser.ProhibitLogin: + err = user_model.ErrUserProhibitLogin{UID: authUser.ID, Name: authUser.Name} + default: return authUser, source, nil } - err = user_model.ErrUserProhibitLogin{UID: authUser.ID, Name: authUser.Name} } if user_model.IsErrUserNotExist(err) { diff --git a/services/auth/signin_test.go b/services/auth/signin_test.go new file mode 100644 index 00000000000..ed2acf364be --- /dev/null +++ b/services/auth/signin_test.go @@ -0,0 +1,47 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "testing" + + auth_model "gitea.dev/models/auth" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockBotSource struct { + auth_model.ConfigBase +} + +func (s *mockBotSource) FromDB(bs []byte) error { return nil } +func (s *mockBotSource) ToDB() ([]byte, error) { return []byte("{}"), nil } + +func (s *mockBotSource) Authenticate(ctx context.Context, _ *user_model.User, login, _ string) (*user_model.User, error) { + return user_model.GetUserByName(ctx, login) +} + +const mockBotSourceType auth_model.Type = 100 + +func TestUserSignIn_BotCannotSignIn(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + auth_model.RegisterTypeConfig(mockBotSourceType, &mockBotSource{}) + + bot := &user_model.User{Name: "test-bot", Email: "test-bot@example.com", Type: user_model.UserTypeBot, IsActive: true} + require.NoError(t, user_model.AdminCreateUser(t.Context(), bot, &user_model.Meta{})) + require.NoError(t, db.Insert(t.Context(), &auth_model.Source{ + Type: mockBotSourceType, + Name: "mock-bot-source", + IsActive: true, + Cfg: &mockBotSource{}, + })) + + _, _, err := UserSignIn(t.Context(), "test-bot", "") + assert.ErrorAs(t, err, &user_model.ErrUserNotExist{}) +} diff --git a/services/auth/sspi.go b/services/auth/sspi.go index eee92c931c5..26ea642d50c 100644 --- a/services/auth/sspi.go +++ b/services/auth/sspi.go @@ -119,6 +119,9 @@ func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore, log.Error("CreateUser: %v", err) return nil, err } + } else if !user.IsIndividual() { + log.Trace("SSPI Authorization: user %q is not an individual, ignoring", username) + return nil, nil //nolint:nilnil // the auth method is not applicable } if s.CreateSession { diff --git a/services/convert/user.go b/services/convert/user.go index d9ed124f04b..b7017331016 100644 --- a/services/convert/user.go +++ b/services/convert/user.go @@ -9,8 +9,32 @@ import ( "gitea.dev/models/perm" user_model "gitea.dev/models/user" api "gitea.dev/modules/structs" + "gitea.dev/modules/util" ) +func userTypeToString(t user_model.UserType) api.UserTypeString { + switch t { + case user_model.UserTypeOrganization, user_model.UserTypeOrganizationReserved: + return api.UserTypeStringOrganization + case user_model.UserTypeBot: + return api.UserTypeStringBot + default: + return api.UserTypeStringUser + } +} + +// UserTypeFromString parses a user type an admin may create or convert to +func UserTypeFromString(s api.UserTypeString) (user_model.UserType, error) { + switch s { + case api.UserTypeStringUser: + return user_model.UserTypeIndividual, nil + case api.UserTypeStringBot: + return user_model.UserTypeBot, nil + default: + return 0, util.NewInvalidArgumentErrorf("invalid user type %q (expected %s, %s)", s, api.UserTypeStringUser, api.UserTypeStringBot) + } +} + // ToUser convert user_model.User to api.User // if doer is set, private information is added if the doer has the permission to see it func ToUser(ctx context.Context, user, doer *user_model.User) *api.User { @@ -50,6 +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), FullName: user.FullName, Email: user.GetPlaceholderEmail(), AvatarURL: user.AvatarLink(ctx), diff --git a/services/forms/admin.go b/services/forms/admin.go index 067cced6ef0..54e6b721c64 100644 --- a/services/forms/admin.go +++ b/services/forms/admin.go @@ -13,9 +13,10 @@ type AdminCreateUserForm struct { middleware.FormDefaultValidator LoginType string `binding:"Required"` LoginName string - UserName string `binding:"Required;Username;MaxSize(40)"` - Email string `binding:"Required;Email;MaxSize(254)"` - Password string `binding:"MaxSize(255)"` + UserName string `binding:"Required;Username;MaxSize(40)"` + UserType structs.UserTypeString `binding:"In(,User,Bot)"` + Email string `binding:"Required;Email;MaxSize(254)"` + Password string `binding:"MaxSize(255)"` SendNotify bool MustChangePassword bool Visibility structs.VisibleType @@ -39,8 +40,9 @@ type AdminEditBadgeForm struct { // AdminEditUserForm form for admin to create user type AdminEditUserForm struct { middleware.FormDefaultValidator - LoginType string `binding:"Required"` - UserName string `binding:"Username;MaxSize(40)"` + LoginType string `binding:"Required"` + UserType structs.UserTypeString `binding:"In(,User,Bot)"` + UserName string `binding:"Username;MaxSize(40)"` LoginName string FullName string `binding:"MaxSize(100)"` Email string `binding:"Required;Email;MaxSize(254)"` diff --git a/services/mailer/mail_issue.go b/services/mailer/mail_issue.go index c5e0e039f84..85eabb4a37b 100644 --- a/services/mailer/mail_issue.go +++ b/services/mailer/mail_issue.go @@ -120,8 +120,7 @@ func mailIssueCommentBatch(ctx context.Context, comment *mailComment, users []*u langMap := make(map[string][]*user_model.User) for _, user := range users { - if !user.IsActive { - // Exclude deactivated users + if !user.IsMailable() { continue } // At this point we exclude: @@ -205,8 +204,7 @@ func SendIssueAssignedMail(ctx context.Context, issue *issues_model.Issue, doer langMap := make(map[string][]*user_model.User) for _, user := range recipients { - if !user.IsActive { - // don't send emails to inactive users + if !user.IsMailable() { continue } langMap[user.Language] = append(langMap[user.Language], user) diff --git a/services/mailer/mail_repo.go b/services/mailer/mail_repo.go index 7dde1b293e5..afca174c1b7 100644 --- a/services/mailer/mail_repo.go +++ b/services/mailer/mail_repo.go @@ -38,8 +38,7 @@ func SendRepoTransferNotifyMail(ctx context.Context, doer, newOwner *user_model. langMap := make(map[string][]*user_model.User) for _, user := range users { - if !user.IsActive { - // don't send emails to inactive users + if !user.IsMailable() { continue } langMap[user.Language] = append(langMap[user.Language], user) @@ -54,6 +53,9 @@ func SendRepoTransferNotifyMail(ctx context.Context, doer, newOwner *user_model. return nil } + if newOwner.IsTypeBot() { + return nil + } return sendRepoTransferNotifyMailPerLang(newOwner.Language, newOwner, doer, []*user_model.User{newOwner}, repo) } @@ -98,7 +100,7 @@ func sendRepoTransferNotifyMailPerLang(lang string, newOwner, doer *user_model.U // SendCollaboratorMail sends mail notification to new collaborator. func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository) { - if setting.MailService == nil || !u.IsActive { + if setting.MailService == nil || !u.IsMailable() { return } locale := translation.NewLocale(u.Language) diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go index 23214f12758..b46de7e9c36 100644 --- a/services/mailer/mail_test.go +++ b/services/mailer/mail_test.go @@ -168,6 +168,28 @@ func TestMailMentionsComment(t *testing.T) { assert.Equal(t, 3, mails) } +func TestMailsSkipBots(t *testing.T) { + doer, repo, issue, comment := prepareMailerTest(t) + comment.Poster = doer + var recipients []string + defer test.MockVariableValue(&SendAsync, func(msgs ...*sender_service.Message) { + for _, msg := range msgs { + recipients = append(recipients, msg.To) + } + })() + + require.NoError(t, user_model.UpdateUserCols(t.Context(), &user_model.User{ID: 5, Type: user_model.UserTypeBot}, "type")) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + require.NoError(t, SendIssueAssignedMail(t.Context(), issue, doer, "", comment, []*user_model.User{user, bot})) + require.NoError(t, MailParticipantsComment(t.Context(), comment, activities_model.ActionCommentIssue, issue, []*user_model.User{bot})) + require.NoError(t, SendRepoTransferNotifyMail(t.Context(), doer, bot, repo)) + SendCollaboratorMail(bot, doer, repo) + SendRegisterNotifyMail(bot) + assert.Contains(t, recipients, user.Email) + assert.NotContains(t, strings.Join(recipients, " "), bot.Email) +} + func TestComposeIssueMessage(t *testing.T) { doer, _, issue, _ := prepareMailerTest(t) diff --git a/services/mailer/mail_user.go b/services/mailer/mail_user.go index 2eb896104e7..066a1b9059d 100644 --- a/services/mailer/mail_user.go +++ b/services/mailer/mail_user.go @@ -101,8 +101,7 @@ func SendActivateEmailMail(u *user_model.User, email string) { // SendRegisterNotifyMail triggers a notify e-mail by admin created a account. func SendRegisterNotifyMail(u *user_model.User) { - if setting.MailService == nil || !u.IsActive { - // No mail service configured OR user is inactive + if setting.MailService == nil || !u.IsMailable() { return } locale := translation.NewLocale(u.Language) diff --git a/services/user/update.go b/services/user/update.go index 02660ac79ea..95e2297f0bc 100644 --- a/services/user/update.go +++ b/services/user/update.go @@ -14,6 +14,7 @@ import ( "gitea.dev/modules/optional" "gitea.dev/modules/setting" "gitea.dev/modules/structs" + "gitea.dev/modules/util" "gitea.dev/services/audit" ) @@ -55,6 +56,7 @@ type UpdateOptions struct { AllowCreateOrganization optional.Option[bool] IsActive optional.Option[bool] IsAdmin optional.Option[UpdateOptionField[bool]] + UserType optional.Option[user_model.UserType] EmailNotificationsPreference optional.Option[string] SetLastLogin bool RepoAdminChangeTeamAccess optional.Option[bool] @@ -63,7 +65,7 @@ type UpdateOptions struct { func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) error { cols := make([]string, 0, 20) - oldIsActive, oldIsRestricted, oldIsAdmin, oldVisibility := u.IsActive, u.IsRestricted, u.IsAdmin, u.Visibility + oldIsActive, oldIsRestricted, oldIsAdmin, oldVisibility, oldType := u.IsActive, u.IsRestricted, u.IsAdmin, u.Visibility, u.Type if opts.KeepEmailPrivate.Has() { u.KeepEmailPrivate = opts.KeepEmailPrivate.Value() @@ -175,6 +177,14 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er cols = append(cols, "repo_admin_change_team_access") } + if opts.UserType.Has() && opts.UserType.Value() != u.Type { + if err := CheckConvertUserType(u); err != nil { + return err + } + u.Type = opts.UserType.Value() + cols = append(cols, "type") + } + if opts.EmailNotificationsPreference.Has() { u.EmailNotificationsPreference = opts.EmailNotificationsPreference.Value() @@ -203,6 +213,9 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er if u.Visibility != oldVisibility { audit.Record(ctx, audit_model.UserVisibility, u, "old_visibility", oldVisibility.String(), "new_visibility", u.Visibility.String()) } + if u.Type != oldType { + audit.Record(ctx, audit_model.UserType, u, "user_type", u.Type.DisplayName()) + } return nil } @@ -216,6 +229,9 @@ type UpdateAuthOptions struct { } func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions) error { + if u.IsTypeBot() && (opts.Password.Has() || opts.LoginSource.Value() != 0 || opts.LoginName.Value() != "") { + return util.NewInvalidArgumentErrorf("a bot account cannot have a password or authentication source") + } loginSourceChanged := false authSourceName := "" if opts.LoginSource.Has() { @@ -279,3 +295,15 @@ func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions return nil } + +func CheckConvertUserType(u *user_model.User) error { + switch { + case u.IsAdmin: + return user_model.ErrBotCanNotBeAdmin + case !u.IsIndividual() && !u.IsTypeBot(): + return user_model.ErrUserTypeCanNotConvert + case !u.IsLocal(): + return user_model.ErrBotMustBeLocal + } + return nil +} diff --git a/services/user/update_test.go b/services/user/update_test.go index 8ef59a99c02..7665c48f144 100644 --- a/services/user/update_test.go +++ b/services/user/update_test.go @@ -6,6 +6,8 @@ package user import ( "testing" + audit_model "gitea.dev/models/audit" + auth_model "gitea.dev/models/auth" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" password_module "gitea.dev/modules/auth/password" @@ -13,6 +15,7 @@ import ( "gitea.dev/modules/setting" "gitea.dev/modules/structs" "gitea.dev/modules/test" + "gitea.dev/modules/util" "github.com/stretchr/testify/assert" ) @@ -153,3 +156,40 @@ func TestUpdateUserVisibility(t *testing.T) { Visibility: optional.Some(structs.VisibleTypePublic), })) } + +func TestConvertUserType(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + before := *user + tokensBefore := unittest.GetCount(t, &auth_model.AccessToken{UID: user.ID}) + assert.NotEmpty(t, before.Passwd) + assert.Positive(t, tokensBefore) + + defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDatabase)() + assert.NoError(t, UpdateUser(t.Context(), user, &UpdateOptions{UserType: optional.Some(user_model.UserTypeBot)})) + assert.True(t, user.IsTypeBot()) + unittest.AssertExistsAndLoadBean(t, &audit_model.Event{Action: audit_model.UserType, ScopeType: audit_model.ScopeUser, ScopeID: user.ID}) + + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + before.Type, before.UpdatedUnix = user_model.UserTypeBot, user.UpdatedUnix + assert.Equal(t, before, *user) + assert.Equal(t, tokensBefore, unittest.GetCount(t, &auth_model.AccessToken{UID: user.ID})) + + assert.ErrorIs(t, UpdateAuth(t.Context(), user, &UpdateAuthOptions{Password: optional.Some("%$DRZUVB576tfzgu")}), util.ErrInvalidArgument) + assert.ErrorIs(t, UpdateAuth(t.Context(), user, &UpdateAuthOptions{LoginSource: optional.Some(int64(1))}), util.ErrInvalidArgument) + assert.ErrorIs(t, UpdateAuth(t.Context(), user, &UpdateAuthOptions{LoginName: optional.Some("cn=bot")}), util.ErrInvalidArgument) + assert.ErrorIs(t, UpdateUser(t.Context(), user, &UpdateOptions{IsAdmin: UpdateOptionFieldFromValue(true)}), user_model.ErrBotCanNotBeAdmin) + assert.False(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).IsAdmin) + + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + assert.NoError(t, UpdateUser(t.Context(), user, &UpdateOptions{UserType: optional.Some(user_model.UserTypeIndividual)})) + assert.True(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).IsIndividual()) + + toBot := &UpdateOptions{UserType: optional.Some(user_model.UserTypeBot)} + assert.ErrorIs(t, UpdateUser(t.Context(), unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}), toBot), user_model.ErrUserTypeCanNotConvert) + assert.ErrorIs(t, UpdateUser(t.Context(), unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), toBot), user_model.ErrBotCanNotBeAdmin) + assert.True(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).IsIndividual()) + assert.NoError(t, user_model.UpdateUserCols(t.Context(), &user_model.User{ID: 4, LoginType: auth_model.LDAP}, "login_type")) + assert.ErrorIs(t, UpdateUser(t.Context(), unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}), toBot), user_model.ErrBotMustBeLocal) +} diff --git a/templates/admin/user/edit.tmpl b/templates/admin/user/edit.tmpl index 400696591bb..9dd9f3ff7c1 100644 --- a/templates/admin/user/edit.tmpl +++ b/templates/admin/user/edit.tmpl @@ -10,24 +10,41 @@ - -
- -