Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
63b8cba
ui
bircni Jun 21, 2026
966e1b4
refactor: share access token scope parsing between admin and user han…
bircni Jun 21, 2026
aade8ce
fix(auth): block bot and organization accounts from interactive sign-in
bircni Jun 21, 2026
1213500
feat: convert users between individual and bot via UI, API and CLI
bircni Jun 21, 2026
5100868
docs: add bot user design document
bircni Jun 21, 2026
5e6da3a
fix: harden bot account creation, conversion and token management
bircni Jun 21, 2026
fa53f69
refactor
bircni Jun 21, 2026
30a0cb7
fix lint
bircni Jun 21, 2026
921f945
fixes
bircni Jun 28, 2026
ebbe09b
fix(admin): block impersonation of bot users
bircni Jul 27, 2026
58b8e51
feat(admin): add user type filter to the admin user list
bircni Jul 27, 2026
c559072
adress comments
bircni Jul 28, 2026
8ffb179
adress comments
bircni Jul 28, 2026
4207ac4
cleanup
bircni Aug 11, 2026
17ef61a
adress feedback
bircni Aug 14, 2026
5b0bf5a
cleanup
bircni Aug 14, 2026
4997349
review: address remaining feedback on bot-account PR
joestump-agent Aug 18, 2026
d8efcf5
test(activities): cover org-branch bot skip in repo transfer notifica…
joestump Aug 18, 2026
bd3affa
docs: restore and expand the bot user design document
joestump Aug 18, 2026
24ead7f
cleanup
bircni Aug 18, 2026
1577db5
fix: repair tests after rebase onto new form-binding API
joestump-agent Aug 19, 2026
dd6b39d
Merge branch 'main' into feat/bot-user-ui
joestump Aug 19, 2026
da2a1bd
fix: use checked type assertions in handleAdminCreateUserError
joestump-agent Aug 19, 2026
45b482b
Merge branch 'main' into feat/bot-user-ui
joestump Aug 20, 2026
62ec566
Merge remote-tracking branch 'upstream/main' into restore/bot-user-ui
joestump-agent Aug 23, 2026
86c9a94
review: address silverwind and copilot feedback on bot-account PR
joestump-agent Aug 22, 2026
32cee65
Merge branch 'main' into feat/bot-user-ui
joestump Aug 23, 2026
a70845f
fix: resolve merge conflict in reverseproxy_test.go
joestump-agent Aug 23, 2026
138e70a
refactor: prefix admin user form classes with js- for grepability
joestump-agent Aug 24, 2026
199cf8a
Merge branch 'main' into feat/bot-user-ui
joestump Aug 25, 2026
c8381d9
Merge remote-tracking branch 'upstream/main' into fix-38966
joestump-agent Aug 27, 2026
7c82f14
Merge branch 'main' into feat/bot-user-ui
joestump Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/admin_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ func newUserCommand() *cli.Command {
newUserGenerateAccessTokenCommand(),
microcmdUserMustChangePassword(),
microcmdUserDisableTwoFactor(),
microcmdUserChangeType(),
},
}
}
1 change: 1 addition & 0 deletions cmd/admin_user_change_password_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func TestChangePasswordCommand(t *testing.T) {

defer func() {
require.NoError(t, db.TruncateBeans(t.Context(), &user_model.User{}))
require.NoError(t, db.TruncateBeans(t.Context(), &user_model.EmailAddress{}))
}()

t.Run("change password successfully", func(t *testing.T) {
Expand Down
61 changes: 61 additions & 0 deletions cmd/admin_user_change_type.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package cmd

import (
"context"
"fmt"

user_model "gitea.dev/models/user"
"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 a user between the individual 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: individual or bot",
Required: true,
},
},
}
}

func runChangeUserType(ctx context.Context, c *cli.Command) error {
targetType, err := user_model.ParseUserType(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.ConvertUserType(ctx, user, targetType); err != nil {
return err
}

fmt.Printf("%s's type has been successfully changed to %s!\n", user.Name, c.String("user-type"))

Check failure on line 59 in cmd/admin_user_change_type.go

View workflow job for this annotation

GitHub Actions / lint-backend

use of `fmt.Printf` forbidden by pattern `^(fmt\.Print(|f|ln)|print|println)$` (forbidigo)

Check failure on line 59 in cmd/admin_user_change_type.go

View workflow job for this annotation

GitHub Actions / lint-backend

use of `fmt.Printf` forbidden by pattern `^(fmt\.Print(|f|ln)|print|println)$` (forbidigo)
return nil
}
74 changes: 74 additions & 0 deletions cmd/admin_user_change_type_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package cmd

import (
"io"
"testing"

"gitea.dev/models/db"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestChangeTypeCommand(t *testing.T) {
ctx := t.Context()

defer func() {
require.NoError(t, db.TruncateBeans(t.Context(), &user_model.User{}))
require.NoError(t, db.TruncateBeans(t.Context(), &user_model.EmailAddress{}))
}()

t.Run("convert individual to bot and back", func(t *testing.T) {
require.NoError(t, microcmdUserCreate().Run(ctx, []string{"create", "--username", "testuser", "--email", "testuser@gitea.local", "--random-password"}))
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"})
assert.True(t, user.IsIndividual())

require.NoError(t, microcmdUserChangeType().Run(ctx, []string{"change-type", "--username", "testuser", "--user-type", "bot"}))
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"})
assert.True(t, user.IsTypeBot())
assert.Empty(t, user.Passwd)

require.NoError(t, microcmdUserChangeType().Run(ctx, []string{"change-type", "--username", "testuser", "--user-type", "individual"}))
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"})
assert.True(t, user.IsIndividual())
})

t.Run("failure cases", func(t *testing.T) {
testCases := []struct {
name string
args []string
expectedErr string
}{
{
name: "invalid user type",
args: []string{"change-type", "--username", "testuser", "--user-type", "invalid"},
expectedErr: "invalid user type",
},
{
name: "missing username",
args: []string{"change-type", "--user-type", "bot"},
expectedErr: `"username" not set`,
},
{
name: "missing user-type",
args: []string{"change-type", "--username", "testuser"},
expectedErr: `"user-type" not set`,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cmd := microcmdUserChangeType()
cmd.Writer, cmd.ErrWriter = io.Discard, io.Discard
err := cmd.Run(ctx, tc.args)
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectedErr)
})
}
})
}
14 changes: 7 additions & 7 deletions cmd/admin_user_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,9 @@ 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 := user_model.ParseUserType(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.
Expand All @@ -120,6 +116,10 @@ func runCreateUser(ctx context.Context, c *cli.Command) error {
if c.IsSet("password") || c.IsSet("random-password") {
return errors.New("password can only be set for individual users")
}
// automation does not need site-wide root access
if c.Bool("admin") {
return errors.New("admin flag can only be set for individual users")
}
}

if c.IsSet("password") && c.IsSet("random-password") {
Expand Down
1 change: 1 addition & 0 deletions cmd/admin_user_create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func TestAdminUserCreate(t *testing.T) {
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", "--admin"), "can only be set for individual users")

assert.NoError(t, createUser("u", "--user-type", "bot"))
u := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "u"})
Expand Down
1 change: 1 addition & 0 deletions cmd/admin_user_must_change_password_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
func TestMustChangePassword(t *testing.T) {
defer func() {
require.NoError(t, db.TruncateBeans(t.Context(), &user_model.User{}))
require.NoError(t, db.TruncateBeans(t.Context(), &user_model.EmailAddress{}))
}()
err := microcmdUserCreate().Run(t.Context(), []string{"create", "--username", "testuser", "--email", "testuser@gitea.local", "--random-password"})
require.NoError(t, err)
Expand Down
10 changes: 8 additions & 2 deletions models/activities/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,18 @@ func CreateRepoTransferNotification(ctx context.Context, doer, newOwner *user_mo
return err
}
for i := range users {
if users[i].IsTypeBot() {
continue
}
notify = append(notify, &Notification{
UserID: i,
UserID: users[i].ID,
RepoID: repo.ID,
Status: NotificationStatusUnread,
UpdatedBy: doer.ID,
Source: NotificationSourceRepository,
})
Comment thread
silverwind marked this conversation as resolved.
}
} else {
} else if !newOwner.IsTypeBot() {
notify = []*Notification{{
UserID: newOwner.ID,
RepoID: repo.ID,
Expand All @@ -144,6 +147,9 @@ func CreateRepoTransferNotification(ctx context.Context, doer, newOwner *user_mo
}}
}

if len(notify) == 0 {
return nil
}
return db.Insert(ctx, notify)
})
}
Expand Down
3 changes: 3 additions & 0 deletions models/activities/notification_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
48 changes: 48 additions & 0 deletions models/activities/notification_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ func TestCreateOrUpdateIssueNotifications(t *testing.T) {
assert.Equal(t, activities_model.NotificationStatusUnread, notf.Status)
}

func TestCreateRepoTransferNotificationOrgSkipsBot(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
assert.True(t, org.IsOrganization())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})

// user28 and user2 can both create repos in org3; as a bot, user28 is skipped
bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
bot.Type = user_model.UserTypeBot
assert.NoError(t, user_model.UpdateUserCols(t.Context(), bot, "type"))

assert.NoError(t, activities_model.CreateRepoTransferNotification(t.Context(), doer, org, repo))

// the non-bot member is notified under its real user id, the bot is not notified at all
notf := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 2, RepoID: repo.ID, Source: activities_model.NotificationSourceRepository})
assert.Equal(t, activities_model.NotificationStatusUnread, notf.Status)
unittest.AssertNotExistsBean(t, &activities_model.Notification{UserID: bot.ID, RepoID: repo.ID, Source: activities_model.NotificationSourceRepository})
}

func TestCreateOrUpdateIssueNotificationsForAssigneeAndReviewer(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())

Expand Down Expand Up @@ -66,6 +86,34 @@ func TestCreateOrUpdateIssueNotificationsIgnored(t *testing.T) {
assert.Empty(t, notified)
}

func TestCreateOrUpdateIssueNotificationsSkipsBots(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
user.Type = user_model.UserTypeBot
assert.NoError(t, user_model.UpdateUserCols(t.Context(), user, "type"))

notifiedIDs, err := activities_model.CreateOrUpdateIssueNotifications(t.Context(), issue.ID, 0, 2, 0)
assert.NoError(t, err)
assert.NotContains(t, notifiedIDs, user.ID)

unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 1, IssueID: issue.ID})
unittest.AssertNotExistsBean(t, &activities_model.Notification{UserID: user.ID, IssueID: issue.ID})
}

func TestCreateRepoTransferNotificationSkipsBot(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
newOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
newOwner.Type = user_model.UserTypeBot
assert.NoError(t, user_model.UpdateUserCols(t.Context(), newOwner, "type"))
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})

assert.NoError(t, activities_model.CreateRepoTransferNotification(t.Context(), doer, newOwner, repo))

unittest.AssertNotExistsBean(t, &activities_model.Notification{UserID: newOwner.ID, RepoID: repo.ID, Source: activities_model.NotificationSourceRepository})
}

func TestNotificationsForUser(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
Expand Down
5 changes: 5 additions & 0 deletions models/user/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,8 @@ func IsErrUserIsNotLocal(err error) bool {
_, ok := err.(ErrUserIsNotLocal)
return ok
}

var (
ErrBotCanNotBeAdmin = util.NewInvalidArgumentErrorf("bot user can not be a site administrator")
ErrUserTypeCanNotConvert = util.NewInvalidArgumentErrorf("user type can not be converted")
)
19 changes: 19 additions & 0 deletions models/user/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ const (
UserTypeRemoteUser // 5
)

// convertibleUserTypes maps the user types an admin may create or convert between.
// Only these types have a stable external name, the other ones are internal.
var convertibleUserTypes = map[string]UserType{
"individual": UserTypeIndividual,
"bot": UserTypeBot,
}

// ParseUserType maps an external user type name to its UserType.
func ParseUserType(s string) (UserType, error) {
t, ok := convertibleUserTypes[s]
if !ok {
return 0, util.NewInvalidArgumentErrorf("invalid user type %q, must be one of: individual, bot", s)
}
return t, nil
}

const (
// EmailNotificationsEnabled indicates that the user would like to receive all email notifications except your own
EmailNotificationsEnabled = "enabled"
Expand Down Expand Up @@ -518,6 +534,9 @@ func (u *User) GitName() string {
}

// IsMailable checks if a user is eligible to receive emails.
// Bots (including the Gitea Actions user) and the Ghost user are excluded:
// they have no inbox to read. IsIndividual() rules out bot accounts; ID > 0
// rules out the ghost.
func (u *User) IsMailable() bool {
return u.ID > 0 && u.IsActive && u.IsIndividual()
}
Expand Down
8 changes: 8 additions & 0 deletions modules/structs/admin_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,11 @@ 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: "individual" or "bot"
//
// required: true
UserType string `json:"user_type" binding:"Required;In(individual,bot)"`
}
Loading
Loading