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 <bircni@icloud.com>
Co-authored-by: joestump <joe@joestump.net>
Co-authored-by: Joe Stump <joe@stu.mp>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
Joe (Agent) Stump
2026-09-18 12:43:36 +00:00
committed by GitHub
co-authored by Nicolas joestump Joe Stump silverwind Lunny Xiao
parent db7dbd5a6b
commit 3bec08f998
71 changed files with 1246 additions and 330 deletions
+17
View File
@@ -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(),
},
}
}
+61
View File
@@ -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
}
+8 -12
View File
@@ -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")
+9 -2
View File
@@ -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) {
+8 -2
View File
@@ -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)
})
}
+3
View File
@@ -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
}
+22
View File
@@ -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})
+1
View File
@@ -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}.")
+1 -1
View File
@@ -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:
+6
View File
@@ -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")
)
-14
View File
@@ -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)
+16 -1
View File
@@ -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
}
+9
View File
@@ -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)"`
}
+2
View File
@@ -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
+17
View File
@@ -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"
)
+9
View File
@@ -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(` <span class="ui basic label tw-py-0 tw-align-baseline">%s</span>`, ut.locale().TrString("concept_user_bot"))
}
func filenameIsImage(filename string) bool {
mimeType := mime.TypeByExtension(filepath.Ext(filename))
return strings.HasPrefix(mimeType, "image/")
+5 -6
View File
@@ -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(`<a class="muted" href="%s">%s</a>`, href, participantName(participant))
return htmlutil.HTMLFormat(`<a class="muted" href="%s">%s</a>%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(`<a class="muted" href="mailto:%s">%s</a>`, 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(`<a class="silenced flex-text-block" href="%s">%s<span>%s</span></a>`, href, avatar, name)
return htmlutil.HTMLFormat(`<a class="silenced flex-text-block" href="%s">%s<span>%s</span></a>%s`, href, avatar, name, label)
}
return htmlutil.HTMLFormat(`<span class="flex-text-block">%s<span>%s</span></span>`, avatar, name)
return htmlutil.HTMLFormat(`<span class="flex-text-block">%s<span>%s</span></span>%s`, avatar, name, label)
}
+8 -2
View File
@@ -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 <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.",
"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",
+45 -2
View File
@@ -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)
}
+1
View File
@@ -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)
+3
View File
@@ -59,6 +59,9 @@ type swaggerParameterBodies struct {
// in:body
RenameUserOption api.RenameUserOption
// in:body
ConvertUserTypeOption api.ConvertUserTypeOption
// in:body
CreateLabelOption api.CreateLabelOption
// in:body
+2 -3
View File
@@ -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,
},
+91 -17
View File
@@ -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)
+3
View File
@@ -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)
+63 -35
View File
@@ -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,
})
+3 -1
View File
@@ -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)
})
+3
View File
@@ -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
+6 -4
View File
@@ -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 {
+3 -3
View File
@@ -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 {
+17
View File
@@ -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")()
+6
View File
@@ -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
}
+38
View File
@@ -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)
}
+6 -2
View File
@@ -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) {
+47
View File
@@ -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{})
}
+3
View File
@@ -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 {
+25
View File
@@ -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),
+7 -5
View File
@@ -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)"`
+2 -4
View File
@@ -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)
+5 -3
View File
@@ -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)
+22
View File
@@ -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)
+1 -2
View File
@@ -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)
+29 -1
View File
@@ -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
}
+40
View File
@@ -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)
}
+47 -26
View File
@@ -10,24 +10,41 @@
<label for="user_name">{{ctx.Locale.Tr "username"}}</label>
<input id="user_name" name="user_name" value="{{.User.Name}}" maxlength="40">
</div>
<!-- Types and name -->
<div class="inline required field {{if .Err_LoginType}}error{{end}}">
<label>{{ctx.Locale.Tr "admin.users.auth_source"}}</label>
<div class="ui selection type dropdown">
<input type="hidden" id="login_type" name="login_type" value="{{.LoginSource.Type.Int}}-{{.LoginSource.ID}}" required>
<div class="text">{{ctx.Locale.Tr "admin.users.local"}}</div>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<div class="item" data-value="0-0">{{ctx.Locale.Tr "admin.users.local"}}</div>
{{range .Sources}}
<div class="item" data-value="{{.Type.Int}}-{{.ID}}">{{.Name}}</div>
{{end}}
{{if .CanConvertUserType}}
<div class="required field">
<label for="user_type">{{ctx.Locale.Tr "admin.users.user_type"}}</label>
<div class="ui selection type dropdown">
<input type="hidden" id="user_type" name="user_type" value="{{Iif .User.IsTypeBot "Bot" "User"}}" required>
<div class="text">{{ctx.Locale.Tr (Iif .User.IsTypeBot "concept_user_bot" "concept_user_user")}}</div>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<div class="item" data-value="User">{{ctx.Locale.Tr "concept_user_user"}}</div>
<div class="item" data-value="Bot">{{ctx.Locale.Tr "concept_user_bot"}}</div>
</div>
</div>
</div>
</div>
{{end}}
{{if not .User.IsTypeBot}}
<div class="required js-non-bot field {{if .Err_LoginType}}error{{end}}">
<label for="login_type">{{ctx.Locale.Tr "admin.users.auth_source"}}</label>
<div class="ui selection type dropdown">
<input type="hidden" id="login_type" name="login_type" value="{{.LoginSource.Type.Int}}-{{.LoginSource.ID}}" required>
<div class="text">{{ctx.Locale.Tr "admin.users.local"}}</div>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<div class="item" data-value="0-0">{{ctx.Locale.Tr "admin.users.local"}}</div>
{{range .Sources}}
<div class="item" data-value="{{.Type.Int}}-{{.ID}}">{{.Name}}</div>
{{end}}
</div>
</div>
</div>
{{else}}
<input type="hidden" name="login_type" value="0-0">
{{end}}
<div class="inline field {{if .Err_Visibility}}error{{end}}">
<span class="inline required field"><label for="visibility">{{ctx.Locale.Tr "settings.visibility"}}</label></span>
<div class="required field {{if .Err_Visibility}}error{{end}}">
<label for="visibility">{{ctx.Locale.Tr "settings.visibility"}}</label>
<div class="ui selection type dropdown">
<input type="hidden" id="visibility" name="visibility" value="{{if .User.Visibility.IsPublic}}0{{else if .User.Visibility.IsLimited}}1{{else}}2{{end}}">
<div class="text">
@@ -50,7 +67,7 @@
</div>
</div>
<div class="required non-local field {{if .Err_LoginName}}error{{end}} {{if eq .User.LoginSource 0}}tw-hidden{{end}}">
<div class="required js-non-local js-non-bot field {{if .Err_LoginName}}error{{end}} {{if eq .User.LoginSource 0}}tw-hidden{{end}}">
<label for="login_name">{{ctx.Locale.Tr "admin.users.auth_login_name"}}</label>
<input id="login_name" name="login_name" value="{{.User.LoginName}}">
</div>
@@ -62,11 +79,13 @@
<label for="email">{{ctx.Locale.Tr "email"}}</label>
<input id="email" name="email" type="email" value="{{.User.Email}}" required>
</div>
<div class="local field {{if .Err_Password}}error{{end}} {{if not (or (.User.IsLocal) (.User.IsOAuth2))}}tw-hidden{{end}}">
<label for="password">{{ctx.Locale.Tr "password"}}</label>
<input id="password" name="password" type="password" autocomplete="new-password">
<p class="help">{{ctx.Locale.Tr "admin.users.password_helper"}}</p>
</div>
{{if not .User.IsTypeBot}}
<div class="js-local js-non-bot field {{if .Err_Password}}error{{end}} {{if not (or .User.IsLocal .User.IsOAuth2)}}tw-hidden{{end}}">
<label for="password">{{ctx.Locale.Tr "password"}}</label>
<input id="password" name="password" type="password" autocomplete="new-password">
<p class="help">{{ctx.Locale.Tr "admin.users.password_helper"}}</p>
</div>
{{end}}
<div class="field {{if .Err_Language}}error{{end}}">
<label for="language">{{ctx.Locale.Tr "settings.language"}}</label>
@@ -113,12 +132,14 @@
<input name="prohibit_login" type="checkbox" {{if .User.ProhibitLogin}}checked{{end}} {{if (eq .User.ID .SignedUserID)}}disabled{{end}}>
</div>
</div>
<div class="inline field">
<div class="ui checkbox">
<label><strong>{{ctx.Locale.Tr "admin.users.is_admin"}}</strong></label>
<input name="admin" type="checkbox" {{if .User.IsAdmin}}checked{{end}}>
{{if not .User.IsTypeBot}}
<div class="inline js-non-bot field">
<div class="ui checkbox">
<label><strong>{{ctx.Locale.Tr "admin.users.is_admin"}}</strong></label>
<input name="admin" type="checkbox" {{if .User.IsAdmin}}checked{{end}}>
</div>
</div>
</div>
{{end}}
<div class="inline field">
<div class="ui checkbox">
<label><strong>{{ctx.Locale.Tr "admin.users.is_restricted"}}</strong></label>
+15 -6
View File
@@ -18,7 +18,7 @@
<span class="text">{{ctx.Locale.Tr "admin.users.list_status_filter.menu_text"}}</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu flex-items-menu">
<a class="item j-reset-status-filter">{{ctx.Locale.Tr "admin.users.list_status_filter.reset"}}</a>
<a class="item js-reset-status-filter">{{ctx.Locale.Tr "admin.users.list_status_filter.reset"}}</a>
<div class="divider"></div>
<label class="item"><input type="radio" name="status_filter[is_admin]" value="1"> {{ctx.Locale.Tr "admin.users.list_status_filter.is_admin"}}</label>
<label class="item"><input type="radio" name="status_filter[is_admin]" value="0"> {{ctx.Locale.Tr "admin.users.list_status_filter.not_admin"}}</label>
@@ -37,6 +37,16 @@
</div>
</div>
<div class="ui dropdown type jump item">
<span class="text">{{ctx.Locale.Tr "admin.users.user_type"}}</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu flex-items-menu">
<label class="item"><input type="radio" name="user_type" value="" {{if not $.UserTypeFilter}}checked{{end}}> {{ctx.Locale.Tr "all"}}</label>
<label class="item"><input type="radio" name="user_type" value="User" {{if eq $.UserTypeFilter "User"}}checked{{end}}> {{ctx.Locale.Tr "concept_user_user"}}</label>
<label class="item"><input type="radio" name="user_type" value="Bot" {{if eq $.UserTypeFilter "Bot"}}checked{{end}}> {{ctx.Locale.Tr "concept_user_bot"}}</label>
</div>
</div>
<!-- Sort Menu Item -->
<div class="ui dropdown type jump item">
<span class="text">
@@ -83,14 +93,13 @@
<td>
<a href="{{.HomeLink}}">{{.Name}}</a>
{{if .IsAdmin}}
<span class="ui mini label">{{ctx.Locale.Tr "admin.users.admin"}}</span>
<span class="ui basic label tw-py-0 tw-align-baseline">{{ctx.Locale.Tr "admin.users.admin"}}</span>
{{else if eq 2 .Type}}{{/* Reserved user */}}
<span class="ui mini label">{{ctx.Locale.Tr "admin.users.reserved"}}</span>
{{else if eq 4 .Type}}{{/* Bot "user" */}}
<span class="ui mini label">{{ctx.Locale.Tr "admin.users.bot"}}</span>
<span class="ui basic label tw-py-0 tw-align-baseline">{{ctx.Locale.Tr "admin.users.reserved"}}</span>
{{else if eq 5 .Type}}{{/* Remote user */}}
<span class="ui mini label">{{ctx.Locale.Tr "admin.users.remote"}}</span>
<span class="ui basic label tw-py-0 tw-align-baseline">{{ctx.Locale.Tr "admin.users.remote"}}</span>
{{end}}
{{template "shared/user/user_type_label" .}}
</td>
<td class="gt-ellipsis tw-max-w-48">{{.Email}}</td>
<td>{{svg (Iif .IsActive "octicon-check" "octicon-x")}}</td>
+25 -14
View File
@@ -6,11 +6,26 @@
<div class="ui attached segment">
<form class="ui form" action="{{.Link}}" method="post">
{{template "base/disable_form_autofill"}}
<!-- Types and name -->
<div class="inline required field {{if .Err_LoginType}}error{{end}}">
<label>{{ctx.Locale.Tr "admin.users.auth_source"}}</label>
<div class="required field {{if .Err_UserName}}error{{end}}">
<label for="user_name">{{ctx.Locale.Tr "username"}}</label>
<input id="user_name" type="text" name="user_name" value="{{.user_name}}" autofocus required maxlength="40">
</div>
<div class="required field">
<label for="user_type">{{ctx.Locale.Tr "admin.users.user_type"}}</label>
<div class="ui selection type dropdown">
<input type="hidden" id="login_type" name="login_type" value="{{.login_type}}" data-password="required" required>
<input type="hidden" id="user_type" name="user_type" value="{{.user_type}}" required>
<div class="text">{{ctx.Locale.Tr "concept_user_user"}}</div>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<div class="item" data-value="User">{{ctx.Locale.Tr "concept_user_user"}}</div>
<div class="item" data-value="Bot">{{ctx.Locale.Tr "concept_user_bot"}}</div>
</div>
</div>
</div>
<div class="required field js-non-bot {{if .Err_LoginType}}error{{end}}">
<label for="login_type">{{ctx.Locale.Tr "admin.users.auth_source"}}</label>
<div class="ui selection type dropdown">
<input type="hidden" id="login_type" name="login_type" value="{{.login_type}}" required>
<div class="text">{{ctx.Locale.Tr "admin.users.local"}}</div>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
@@ -22,8 +37,8 @@
</div>
</div>
<div class="inline field {{if .Err_Visibility}}error{{end}}">
<span class="inline required field"><label for="visibility">{{ctx.Locale.Tr "settings.visibility"}}</label></span>
<div class="required field {{if .Err_Visibility}}error{{end}}">
<label for="visibility">{{ctx.Locale.Tr "settings.visibility"}}</label>
<div class="ui selection type dropdown">
<input type="hidden" id="visibility" name="visibility" value="{{if .visibility}}{{printf "%d" .visibility}}{{else}}{{printf "%d" .DefaultUserVisibilityMode}}{{end}}">
<div class="text">
@@ -46,24 +61,20 @@
</div>
</div>
<div class="required non-local field {{if .Err_LoginName}}error{{end}} {{if eq .login_type "0-0"}}tw-hidden{{end}}">
<div class="required js-non-local js-non-bot field {{if .Err_LoginName}}error{{end}} {{if eq .login_type "0-0"}}tw-hidden{{end}}">
<label for="login_name">{{ctx.Locale.Tr "admin.users.auth_login_name"}}</label>
<input id="login_name" name="login_name" value="{{.login_name}}">
</div>
<div class="required field {{if .Err_UserName}}error{{end}}">
<label for="user_name">{{ctx.Locale.Tr "username"}}</label>
<input id="user_name" type="text" name="user_name" value="{{.user_name}}" autofocus required maxlength="40">
</div>
<div class="required field {{if .Err_Email}}error{{end}}">
<label for="email">{{ctx.Locale.Tr "email"}}</label>
<input id="email" name="email" type="email" value="{{.email}}" required>
</div>
<div class="required local field {{if .Err_Password}}error{{end}} {{if not (eq .login_type "0-0")}}tw-hidden{{end}}">
<div class="required js-local js-non-bot field {{if .Err_Password}}error{{end}} {{if not (eq .login_type "0-0")}}tw-hidden{{end}}">
<label for="password">{{ctx.Locale.Tr "password"}}</label>
<input id="password" name="password" type="password" autocomplete="new-password" value="{{.password}}" {{if eq .login_type "0-0"}}required{{end}}>
</div>
<div class="inline field local {{if ne .login_type "0-0"}}tw-hidden{{end}}">
<div class="inline field js-local js-non-bot {{if ne .login_type "0-0"}}tw-hidden{{end}}">
<div class="ui checkbox">
<label><strong>{{ctx.Locale.Tr "auth.allow_password_change"}}</strong></label>
<input name="must_change_password" type="checkbox" checked>
@@ -72,7 +83,7 @@
<!-- Send register notify e-mail -->
{{if .CanSendEmail}}
<div class="inline field">
<div class="inline js-non-bot field">
<div class="ui checkbox">
<label><strong>{{ctx.Locale.Tr "admin.users.send_register_notify"}}</strong></label>
<input name="send_notify" type="checkbox" {{if .send_notify}}checked{{end}}>
+6 -1
View File
@@ -6,7 +6,9 @@
<h4 class="ui top attached header flex-left-right">
<span>{{.Title}}</span>
<span class="flex-text-block">
<button type="button" class="ui primary compact tiny basic button link-action" data-url="{{.Link}}/impersonate">{{ctx.Locale.Tr "admin.users.impersonate"}}</button>
{{if not .User.IsTypeBot}}
<button type="button" class="ui primary compact tiny basic button link-action" data-url="{{.Link}}/impersonate">{{ctx.Locale.Tr "admin.users.impersonate"}}</button>
{{end}}
<a class="ui primary compact tiny button" href="{{.Link}}/edit">{{ctx.Locale.Tr "admin.users.edit"}}</a>
</span>
</h4>
@@ -23,6 +25,9 @@
</div>
</div>
</div>
{{if .BotAccessTokens}}
{{template "shared/user/access_tokens" .BotAccessTokens}}
{{end}}
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.repositories"}} ({{ctx.Locale.Tr "admin.total" .ReposTotal}})
</h4>
-3
View File
@@ -9,9 +9,6 @@
{{if .User.IsAdmin}}
<span class="ui basic label">{{ctx.Locale.Tr "admin.users.admin"}}</span>
{{end}}
{{if .User.IsTypeBot}}
<span class="ui basic label">{{ctx.Locale.Tr "admin.users.bot"}}</span>
{{end}}
</div>
<div class="item-body">
<b>{{ctx.Locale.Tr "admin.users.auth_source"}}:</b>
+1 -1
View File
@@ -30,7 +30,7 @@
</div>
<div class="item-main">
<div class="item-title">
{{template "shared/user/name" $org}}
{{template "shared/user/name" $org.AsUser}}
{{if $org.Visibility.IsPrivate}}
<span class="ui basic tiny label">{{ctx.Locale.Tr "repo.desc.private"}}</span>
{{end}}
+1 -1
View File
@@ -137,7 +137,7 @@
{{$committerAvatar := ""}}{{$committerDisplayName := ""}}
{{if .Verification.CommittingUser}}
{{$committerAvatar = ctx.AvatarUtils.Avatar .Verification.CommittingUser 20}}
{{$committerDisplayName = .Verification.CommittingUser.GetShortDisplayNameLinkHTML}}
{{$committerDisplayName = HTMLFormat `%s%s` .Verification.CommittingUser.GetShortDisplayNameLinkHTML (ctx.RenderUtils.UserTypeLabel .Verification.CommittingUser)}}
{{else}}
{{$committerAvatar = ctx.AvatarUtils.AvatarByEmail .Commit.Committer.Email .Commit.Committer.Email 20}}
{{$committerDisplayName = .Commit.Committer.Name}}
@@ -32,9 +32,9 @@
<div class="ui relaxed list muted-links flex-items-block">
<span class="item empty-list {{if $issueAssignees}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_assignees"}}</span>
{{range $issueAssignees}}
<a class="item" href="{{$listBaseLink}}?assignee={{.ID}}">
{{ctx.AvatarUtils.Avatar . 20}} {{.GetDisplayName}}
</a>
<span class="item flex-text-inline">
<a class="muted flex-text-inline" href="{{$listBaseLink}}?assignee={{.ID}}">{{ctx.AvatarUtils.Avatar . 20}} {{.GetDisplayName}}</a>{{template "shared/user/user_type_label" .}}
</span>
{{end}}
</div>
</div>
@@ -51,7 +51,7 @@
<div class="item">
<div class="flex-text-inline tw-flex-1">
{{if .User}}
<a class="muted flex-text-inline tw-gap-2" href="{{.User.HomeLink}}">{{ctx.AvatarUtils.Avatar .User 20}} {{.User.GetDisplayName}}</a>
<a class="muted flex-text-inline tw-gap-2" href="{{.User.HomeLink}}">{{ctx.AvatarUtils.Avatar .User 20}} {{.User.GetDisplayName}}</a>{{template "shared/user/user_type_label" .User}}
{{else if .Team}}
<span class="flex-text-inline tw-gap-2">{{svg "octicon-people" 20}} {{$repoOwnerName}}/{{.Team.Name}}</span>
{{end}}
+2 -2
View File
@@ -78,7 +78,7 @@
{{.Issue.OriginalAuthor}}
<span class="pull-desc">{{ctx.Locale.Tr "repo.pulls.merged_title_desc" .NumCommits $headHref $baseHref $mergedStr}}</span>
{{else}}
<a {{if gt .Issue.PullRequest.Merger.ID 0}}href="{{.Issue.PullRequest.Merger.HomeLink}}"{{end}}>{{.Issue.PullRequest.Merger.GetDisplayName}}</a>
{{template "shared/user/namelink" .Issue.PullRequest.Merger}}
<span class="pull-desc">{{ctx.Locale.Tr "repo.pulls.merged_title_desc" .NumCommits $headHref $baseHref $mergedStr}}</span>
{{end}}
{{else}}
@@ -86,7 +86,7 @@
<span id="pull-desc-display" class="pull-desc">{{.Issue.OriginalAuthor}} {{ctx.Locale.Tr "repo.pulls.title_desc" .NumCommits $headHref $baseHref}}</span>
{{else}}
<span id="pull-desc-display" class="pull-desc">
<a {{if gt .Issue.Poster.ID 0}}href="{{.Issue.Poster.HomeLink}}"{{end}}>{{.Issue.Poster.GetDisplayName}}</a>
{{template "shared/user/namelink" .Issue.Poster}}
{{ctx.Locale.Tr "repo.pulls.title_desc" .NumCommits $headHref $baseHref}}
</span>
{{end}}
+1 -1
View File
@@ -1 +1 @@
<span class="username-display">{{.Name}} {{if .FullName}}<span class="username-fullname">({{.FullName}})</span>{{end}}</span>
<span class="username-display">{{.Name}} {{if .FullName}}<span class="username-fullname">({{.FullName}})</span>{{end}}{{template "shared/user/user_type_label" .}}</span>
+2 -2
View File
@@ -11,9 +11,9 @@
<a href="{{.HomeLink}}">
{{ctx.AvatarUtils.Avatar . 48}}
</a>
<h3 class="name"><a href="{{.HomeLink}}">{{.DisplayName}}</a></h3>
<h3 class="name flex-text-block"><a href="{{.HomeLink}}">{{.DisplayName}}</a>{{template "shared/user/user_type_label" .}}</h3>
<div class="meta">
<div class="meta flex-text-block">
{{if .Website}}
{{svg "octicon-link"}} <a href="{{.Website}}" target="_blank">{{.Website}}</a>
{{else if .Location}}
+125
View File
@@ -0,0 +1,125 @@
<div id="access-token-panel">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.manage_access_token"}}
</h4>
<div class="ui attached segment">
{{if .NewTokenValue}}
<div class="ui positive message">
<div class="flex-text-block tw-justify-center">
<span>{{ctx.Locale.Tr "settings.generate_token_success"}}</span>
<code id="new-access-token-value">{{.NewTokenValue}}</code>
<button type="button" class="btn interact-fg tw-px-1" aria-label="{{ctx.Locale.Tr "copy"}}" data-clipboard-target="#new-access-token-value" data-tooltip-content="{{ctx.Locale.Tr "copy"}}">{{svg "octicon-copy" 14}}</button>
</div>
</div>
{{end}}
<div class="flex-divided-list items-with-main">
<div class="item">
{{if .IsBot}}{{ctx.Locale.Tr "admin.users.bot_token_desc"}}{{else}}{{ctx.Locale.Tr "settings.tokens_desc"}}{{end}}
</div>
{{range $token := .Tokens}}
<div class="item">
<div class="item-leading">
<span class="{{if $token.HasRecentActivity}}tw-text-green{{end}}" {{if $token.HasRecentActivity}}data-tooltip-content="{{ctx.Locale.Tr "settings.token_state_desc"}}"{{end}}>
{{svg "fontawesome-send" 32}}
</span>
</div>
<div class="item-main">
<details>
<summary><span class="item-title">{{$token.Name}}</span></summary>
<p class="tw-my-1">
{{ctx.Locale.Tr "settings.repo_and_org_access"}}:
{{if $token.DisplayPublicOnly}}
{{ctx.Locale.Tr "settings.permissions_public_only"}}
{{else}}
{{ctx.Locale.Tr "settings.permissions_access_all"}}
{{end}}
</p>
<p class="tw-my-1">{{ctx.Locale.Tr "settings.permissions_list"}}</p>
<ul class="tw-my-1">
{{range $scope := $token.Scope.StringSlice}}
{{if ne $scope $.ScopePublicOnly}}
<li>{{$scope}}</li>
{{end}}
{{end}}
</ul>
</details>
<div class="item-body">
<i>{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort $token.CreatedUnix)}}{{svg "octicon-info"}} {{if $token.HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} <span {{if $token.HasRecentActivity}}class="tw-text-green"{{end}}>{{DateUtils.AbsoluteShort $token.UpdatedUnix}}</span>{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}}</i>
</div>
</div>
<div class="item-trailing">
{{if not $.IsBot}}
<button type="button" class="ui tiny button link-action" data-modal-confirm="#regenerate-token" data-url="{{$.Link}}/regenerate?id={{$token.ID}}">
{{svg "octicon-sync"}}
{{ctx.Locale.Tr "settings.regenerate_token"}}
</button>
{{end}}
<button type="button" class="ui red tiny button link-action" data-modal-confirm="#delete-token" data-url="{{$.Link}}/delete?id={{$token.ID}}">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.delete_token"}}
</button>
</div>
</div>
{{end}}
</div>
</div>
<div class="ui bottom attached segment">
<details {{if not .Tokens}}open{{end}}>
<summary><h4 class="ui header tw-inline-block tw-my-2">{{ctx.Locale.Tr "settings.generate_new_token"}}</h4></summary>
<form class="ui form ignore-dirty form-fetch-action" action="{{.Link}}" method="post" data-fetch-sync="$body #access-token-panel">
<div class="field">
<label for="name">{{ctx.Locale.Tr "settings.token_name"}}</label>
<input id="name" name="name" required maxlength="255">
</div>
<div class="field">
<div class="tw-my-2">{{ctx.Locale.Tr "settings.repo_and_org_access"}}</div>
<label class="gt-checkbox">
<input type="radio" name="scope-public-only" value="{{.ScopePublicOnly}}"> {{ctx.Locale.Tr "settings.permissions_public_only"}}
</label>
<label class="gt-checkbox">
<input type="radio" name="scope-public-only" value="" checked> {{ctx.Locale.Tr "settings.permissions_access_all"}}
</label>
</div>
<div>
<div class="tw-my-2">{{ctx.Locale.Tr "settings.access_token_desc" (HTMLFormat `href="%s/api/swagger" target="_blank"` AppSubUrl) (HTMLFormat `href="%s" target="_blank"` "https://docs.gitea.com/development/oauth2-provider#scopes")}}</div>
<table class="ui table unstackable tw-my-2">
{{range $category := .ScopeCategories}}
<tr>
<td>{{$category}}</td>
<td><label class="gt-checkbox"><input type="radio" name="scope-{{$category}}" value="" checked> {{ctx.Locale.Tr "settings.permission_no_access"}}</label></td>
<td><label class="gt-checkbox"><input type="radio" name="scope-{{$category}}" value="read:{{$category}}"> {{ctx.Locale.Tr "settings.permission_read"}}</label></td>
<td><label class="gt-checkbox"><input type="radio" name="scope-{{$category}}" value="write:{{$category}}"> {{ctx.Locale.Tr "settings.permission_write"}}</label></td>
</tr>
{{end}}
</table>
</div>
<button type="submit" class="ui primary button">
{{ctx.Locale.Tr "settings.generate_token"}}
</button>
</form>
</details>
</div>
</div>
<div class="ui small modal" id="delete-token">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.access_token_deletion"}}
</div>
<div class="content">
<p>{{ctx.Locale.Tr "settings.access_token_deletion_desc"}}</p>
</div>
{{template "base/modal_actions_confirm"}}
</div>
{{if not .IsBot}}
<div class="ui small modal" id="regenerate-token">
<div class="header">
{{ctx.Locale.Tr "settings.access_token_regeneration"}}
</div>
<div class="content">
<p>{{ctx.Locale.Tr "settings.access_token_regeneration_desc"}}</p>
</div>
{{template "base/modal_actions_confirm"}}
</div>
{{end}}
+1 -1
View File
@@ -1 +1 @@
<a class="tw-font-semibold"{{if gt .ID 0}} href="{{.HomeLink}}"{{end}}>{{.GetDisplayName}}</a>{{if .IsTypeBot}}&nbsp;<span class="ui basic label tw-p-1 tw-align-baseline">bot</span>{{end}}
<a class="tw-font-semibold"{{if gt .ID 0}} href="{{.HomeLink}}"{{end}}>{{.GetDisplayName}}</a>{{template "shared/user/user_type_label" .}}
+1 -1
View File
@@ -1 +1 @@
<a class="text muted" href="{{.HomeLink}}">{{.Name}}{{if .FullName}} ({{.FullName}}){{end}}</a>
<a class="text muted" href="{{.HomeLink}}">{{.Name}}{{if .FullName}} ({{.FullName}}){{end}}</a>{{template "shared/user/user_type_label" .}}
+1 -1
View File
@@ -1 +1 @@
<a{{if gt .ID 0}} href="{{.HomeLink}}"{{end}}>{{.GetDisplayName}}</a>
<a{{if gt .ID 0}} href="{{.HomeLink}}"{{end}}>{{.GetDisplayName}}</a>{{template "shared/user/user_type_label" .}}
@@ -13,7 +13,7 @@
<div class="item flex-relaxed-list">
<span class="tw-text-center">
{{.ContextUser.Name}}
{{.ContextUser.Name}}{{template "shared/user/user_type_label" .ContextUser}}
{{if .IsAdmin}}
<a href="{{AppSubUrl}}/-/admin/users/{{.ContextUser.ID}}" data-tooltip-content="{{ctx.Locale.Tr "admin.users.details"}}">{{svg "octicon-gear" 18}}</a>
{{end}}
@@ -0,0 +1 @@
{{if .IsTypeBot}} <span class="ui basic label tw-py-0 tw-align-baseline">{{ctx.Locale.Tr "concept_user_bot"}}</span>{{end}}
+75
View File
@@ -3707,6 +3707,25 @@
"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": {
@@ -10722,6 +10741,17 @@
"type": "integer",
"x-go-name": "StarredRepos"
},
"type": {
"description": "the account type",
"enum": [
"User",
"Organization",
"Bot"
],
"type": "string",
"x-go-enum-desc": "User UserTypeStringUser\nOrganization UserTypeStringOrganization\nBot UserTypeStringBot",
"x-go-name": "Type"
},
"visibility": {
"allOf": [
{
@@ -12261,6 +12291,51 @@
]
}
},
"/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",
+76
View File
@@ -1144,6 +1144,52 @@
}
}
},
"/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": [
@@ -26744,6 +26790,25 @@
},
"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",
@@ -33754,6 +33819,17 @@
"format": "int64",
"x-go-name": "StarredRepos"
},
"type": {
"description": "the account 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 option: public, limited, private",
"type": "string",
+1 -110
View File
@@ -1,94 +1,6 @@
{{template "user/settings/layout_head" (dict "pageClass" "user settings applications")}}
<div class="user-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.manage_access_token"}}
</h4>
<div class="ui attached segment">
<div class="flex-divided-list items-with-main">
<div class="item">
{{ctx.Locale.Tr "settings.tokens_desc"}}
</div>
{{range .Tokens}}
<div class="item">
<div class="item-leading">
<span class="{{if .HasRecentActivity}}tw-text-green{{end}}" {{if .HasRecentActivity}}data-tooltip-content="{{ctx.Locale.Tr "settings.token_state_desc"}}"{{end}}>
{{svg "fontawesome-send" 32}}
</span>
</div>
<div class="item-main">
<details>
<summary><span class="item-title">{{.Name}}</span></summary>
<p class="tw-my-1">
{{ctx.Locale.Tr "settings.repo_and_org_access"}}:
{{if .DisplayPublicOnly}}
{{ctx.Locale.Tr "settings.permissions_public_only"}}
{{else}}
{{ctx.Locale.Tr "settings.permissions_access_all"}}
{{end}}
</p>
<p class="tw-my-1">{{ctx.Locale.Tr "settings.permissions_list"}}</p>
<ul class="tw-my-1">
{{range .Scope.StringSlice}}
{{if (ne . $.AccessTokenScopePublicOnly)}}
<li>{{.}}</li>
{{end}}
{{end}}
</ul>
</details>
<div class="item-body">
<i>{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}}{{svg "octicon-info"}} {{if .HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="tw-text-green"{{end}}>{{DateUtils.AbsoluteShort .UpdatedUnix}}</span>{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}}</i>
</div>
</div>
<div class="item-trailing">
<button type="button" class="ui tiny button link-action" data-modal-confirm="#regenerate-token" data-url="{{$.Link}}/regenerate?id={{.ID}}">
{{svg "octicon-sync"}}
{{ctx.Locale.Tr "settings.regenerate_token"}}
</button>
<button type="button" class="ui red tiny button link-action" data-modal-confirm="#delete-token" data-url="{{$.Link}}/delete?id={{.ID}}">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.delete_token"}}
</button>
</div>
</div>
{{end}}
</div>
</div>
<div class="ui bottom attached segment">
<details {{if or .name (not .Tokens)}}open{{end}}>
<summary><h4 class="ui header tw-inline-block tw-my-2">{{ctx.Locale.Tr "settings.generate_new_token"}}</h4></summary>
<form class="ui form ignore-dirty" action="{{.Link}}" method="post">
<div class="field {{if .Err_Name}}error{{end}}">
<label for="name">{{ctx.Locale.Tr "settings.token_name"}}</label>
<input id="name" name="name" value="{{.name}}" required maxlength="255">
</div>
<div class="field">
<div class="tw-my-2">{{ctx.Locale.Tr "settings.repo_and_org_access"}}</div>
<label class="gt-checkbox">
<input type="radio" name="scope-public-only" value="{{$.AccessTokenScopePublicOnly}}"> {{ctx.Locale.Tr "settings.permissions_public_only"}}
</label>
<label class="gt-checkbox">
<input type="radio" name="scope-public-only" value="" checked> {{ctx.Locale.Tr "settings.permissions_access_all"}}
</label>
</div>
<div>
<div class="tw-my-2">{{ctx.Locale.Tr "settings.access_token_desc" (HTMLFormat `href="%s/api/swagger" target="_blank"` AppSubUrl) (HTMLFormat `href="%s" target="_blank"` "https://docs.gitea.com/development/oauth2-provider#scopes")}}</div>
<table class="ui table unstackable tw-my-2">
{{range $category := .TokenCategories}}
<tr>
<td>{{$category}}</td>
<td><label class="gt-checkbox"><input type="radio" name="scope-{{$category}}" value="" checked> {{ctx.Locale.Tr "settings.permission_no_access"}}</label></td>
<td><label class="gt-checkbox"><input type="radio" name="scope-{{$category}}" value="read:{{$category}}"> {{ctx.Locale.Tr "settings.permission_read"}}</label></td>
<td><label class="gt-checkbox"><input type="radio" name="scope-{{$category}}" value="write:{{$category}}"> {{ctx.Locale.Tr "settings.permission_write"}}</label></td>
</tr>
{{end}}
</table>
</div>
<button type="submit" class="ui primary button">
{{ctx.Locale.Tr "settings.generate_token"}}
</button>
</form>
</details>
</div>
{{template "shared/user/access_tokens" .AccessTokens}}
{{if .EnableOAuth2}}
{{template "user/settings/grants_oauth2" .}}
@@ -96,25 +8,4 @@
{{end}}
</div>
<div class="ui small modal" id="delete-token">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.access_token_deletion"}}
</div>
<div class="content">
<p>{{ctx.Locale.Tr "settings.access_token_deletion_desc"}}</p>
</div>
{{template "base/modal_actions_confirm"}}
</div>
<div class="ui small modal" id="regenerate-token">
<div class="header">
{{ctx.Locale.Tr "settings.access_token_regeneration"}}
</div>
<div class="content">
<p>{{ctx.Locale.Tr "settings.access_token_regeneration_desc"}}</p>
</div>
{{template "base/modal_actions_confirm"}}
</div>
{{template "user/settings/layout_footer" .}}
+1 -1
View File
@@ -17,7 +17,7 @@
{{ctx.AvatarUtils.Avatar . 28 "mini"}}
</div>
<div class="item-main">
<div class="item-title">{{template "shared/user/name" .}}</div>
<div class="item-title">{{template "shared/user/name" .AsUser}}</div>
<div class="flex-text-body">
{{.Description}}
</div>
+26
View File
@@ -0,0 +1,26 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {login, randomString} from './utils.ts';
test('create a bot and manage its access token', async ({page, request}) => {
const botName = `e2e-bot-${randomString(8)}`;
await login(page);
await page.goto('/-/admin/users/new');
await page.getByLabel('Username').fill(botName);
await page.getByLabel('Username').press('Tab');
await page.getByRole('option', {name: 'Bot'}).click();
await page.getByLabel('Email Address').fill(`${botName}@${env.GITEA_TEST_E2E_DOMAIN}`);
await page.getByRole('button', {name: 'Create User Account'}).click();
await page.waitForURL(/\/-\/admin\/users\/\d+$/);
await page.getByLabel('Token Name').fill('e2e-token');
await page.getByRole('row', {name: /^user /}).getByRole('radio', {name: 'Read', exact: true}).check();
await page.getByRole('button', {name: 'Generate Token'}).click();
const token = await page.getByRole('code').textContent();
const response = await request.get('/api/v1/user', {headers: {Authorization: `token ${token}`}});
expect(await response.json()).toMatchObject({login: botName, type: 'Bot'});
await page.getByRole('button', {name: 'Delete'}).click();
await page.getByRole('button', {name: 'Yes'}).click();
await expect(page.getByText('The token has been deleted.')).toBeVisible();
});
+1 -1
View File
@@ -35,7 +35,7 @@ func TestAdminAuditLogImpersonation(t *testing.T) {
session.MakeRequest(t, NewRequestWithValues(t, "POST", "/user/settings/applications", map[string]string{
"name": "impersonated-token",
"scope-dummy": "read:user",
}), http.StatusSeeOther)
}), http.StatusOK)
session.MakeRequest(t, NewRequest(t, "GET", "/user/logout"), http.StatusSeeOther)
+130
View File
@@ -9,8 +9,13 @@ import (
"strconv"
"testing"
audit_model "gitea.dev/models/audit"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
@@ -145,3 +150,128 @@ func TestAdminImpersonatedUser(t *testing.T) {
session.MakeRequest(t, NewRequest(t, "GET", "/user/logout"), http.StatusSeeOther)
assert.Equal(t, "", currentUsername(homeDoc(t)))
}
func TestAdminBotUser(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user1")
t.Run("CreateWithoutPassword", func(t *testing.T) {
req := NewRequestWithValues(t, "POST", "/-/admin/users/new", map[string]string{
"user_type": "Bot",
"login_type": "0-0",
"user_name": "bot-user",
"email": "bot-user@example.com",
"visibility": "0",
})
session.MakeRequest(t, req, http.StatusSeeOther)
bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "bot-user"})
assert.True(t, bot.IsTypeBot())
assert.Empty(t, bot.Passwd)
assert.False(t, bot.MustChangePassword)
doc := NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/-/admin/users/%d/edit", bot.ID)), http.StatusOK).Body)
assert.Equal(t, "Bot", doc.Find("#user_type").AttrOr("value", ""))
assert.Empty(t, doc.Find("#login_type").Nodes)
assert.Empty(t, doc.Find("#password").Nodes)
doc = NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/-/admin/users/%d", bot.ID)), http.StatusOK).Body)
assert.NotEmpty(t, doc.Find(`form[action$="/access_tokens"]`).Nodes)
})
t.Run("EditWithoutAuthSource", func(t *testing.T) {
bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "bot-user"})
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/-/admin/users/%d/edit", bot.ID), map[string]string{
"user_name": "bot-user",
"login_type": "0-0",
"email": "bot-user@example.com",
"full_name": "Bot User",
})
session.MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "Bot User", unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: bot.ID}).FullName)
})
t.Run("TokenScope", func(t *testing.T) {
defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDatabase)()
bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "bot-user"})
tokenURL := fmt.Sprintf("/-/admin/users/%d/access_tokens", bot.ID)
resp := session.MakeRequest(t, NewRequestWithValues(t, "POST", tokenURL, map[string]string{
"name": "no-scope",
}), http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "at least one permission")
assert.Equal(t, 0, unittest.GetCount(t, &auth_model.AccessToken{UID: bot.ID}))
resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", tokenURL, map[string]string{
"name": "ci",
"scope-repository": "write:repository",
}), http.StatusOK)
panel := NewHTMLParser(t, resp.Body)
assert.NotEmpty(t, panel.Find("#new-access-token-value").Text())
assert.Equal(t, 1, panel.Find(`[data-clipboard-target="#new-access-token-value"]`).Length())
assert.Equal(t, tokenURL, panel.Find("form.form-fetch-action").AttrOr("action", ""))
assert.Equal(t, 1, unittest.GetCount(t, &auth_model.AccessToken{UID: bot.ID}))
resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", "/-/admin/users/2/access_tokens", map[string]string{
"name": "not-a-bot",
"scope-repository": "write:repository",
}), http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "only be generated for bot accounts")
unittest.AssertNotExistsBean(t, &auth_model.AccessToken{UID: 2, Name: "not-a-bot"})
token := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{UID: bot.ID, Name: "ci"})
session.MakeRequest(t, NewRequestWithValues(t, "POST", tokenURL+"/delete", map[string]string{
"id": strconv.FormatInt(token.ID, 10),
}), http.StatusOK)
assert.Equal(t, 0, unittest.GetCount(t, &auth_model.AccessToken{UID: bot.ID}))
for _, action := range []audit_model.Action{audit_model.UserAccessTokenAdd, audit_model.UserAccessTokenRemove} {
events, _, err := audit_model.FindEvents(t.Context(), &audit_model.EventSearchOptions{Action: action, ScopeType: audit_model.ScopeUser, ScopeID: bot.ID})
require.NoError(t, err)
require.Len(t, events, 1, "audit events for %s", action)
assert.Equal(t, int64(1), events[0].ActorID)
assert.Equal(t, "ci", audit_model.DecodeMetadata(events[0].Metadata)["token"])
}
})
t.Run("APIRejectsAuthSource", func(t *testing.T) {
bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "bot-user"})
req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/users/"+bot.Name, map[string]any{"source_id": 1}).AddBasicAuth("user1")
MakeRequest(t, req, http.StatusBadRequest)
bot = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: bot.ID})
assert.True(t, bot.IsLocal())
assert.Empty(t, bot.LoginName)
})
t.Run("ConvertType", func(t *testing.T) {
editUserType := func(userID int64, userType string) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/-/admin/users/%d/edit", userID), map[string]string{
"user_name": user.Name,
"login_type": "0-0",
"login_name": user.LoginName,
"password": "Bot-Password-1234",
"email": user.Email,
"user_type": userType,
"visibility": "0",
}), http.StatusSeeOther)
}
MakeRequest(t, NewRequestWithJSON(t, "POST", "/api/v1/admin/users/user4/convert-type", map[string]string{"user_type": "Bot"}).AddBasicAuth("user1"), http.StatusNoContent)
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)
assert.Equal(t, api.UserTypeStringBot, DecodeJSON(t, resp, &api.User{}).Type)
session.MakeRequest(t, NewRequest(t, "POST", "/-/admin/users/4/impersonate"), http.StatusBadRequest)
editUserType(4, "User")
assert.True(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}).IsIndividual())
editUserType(4, "Bot")
converted := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
assert.True(t, converted.IsTypeBot())
assert.Equal(t, user4.Passwd, converted.Passwd)
})
}
+2 -7
View File
@@ -189,13 +189,8 @@ func getTokenForLoggedInUser(t testing.TB, session *TestSession, scopes ...auth.
urlValues.Add("scope-dummy", string(scope)) // it only needs to start with "scope-" to be accepted
}
req := NewRequestWithURLValues(t, "POST", "/user/settings/applications", urlValues)
session.MakeRequest(t, req, http.StatusSeeOther)
flashes := session.GetCookieFlashMessage()
assert.NotNil(t, flashes)
if flashes != nil {
return flashes.InfoMsg
}
return ""
resp := session.MakeRequest(t, req, http.StatusOK)
return NewHTMLParser(t, resp.Body).Find("#new-access-token-value").Text()
}
type RequestWrapper struct {
+26 -23
View File
@@ -55,31 +55,34 @@ function initAdminRunnerBulk(toolbar: HTMLElement) {
function initAdminUser() {
const pageContent = document.querySelector('.page-content.admin.edit.user, .page-content.admin.new.user');
if (!pageContent) return;
const elLoginType = document.querySelector<HTMLInputElement>('#login_type');
if (!pageContent || !elLoginType) return;
const isNew = pageContent.classList.contains('new');
const elUserType = document.querySelector<HTMLInputElement>('#user_type');
const elUserName = document.querySelector<HTMLInputElement>('#user_name')!;
const elLoginName = document.querySelector<HTMLInputElement>('#login_name')!;
const elPassword = document.querySelector<HTMLInputElement>('#password')!;
document.querySelector<HTMLInputElement>('#login_type')?.addEventListener('change', function () {
if (this.value?.startsWith('0')) {
document.querySelector<HTMLInputElement>('#user_name')?.removeAttribute('disabled');
document.querySelector<HTMLInputElement>('#login_name')?.removeAttribute('required');
hideElem('.non-local');
showElem('.local');
document.querySelector<HTMLInputElement>('#user_name')?.focus();
if (this.getAttribute('data-password') === 'required') {
document.querySelector('#password')?.setAttribute('required', 'required');
}
} else {
if (document.querySelector<HTMLDivElement>('.admin.edit.user')) {
document.querySelector<HTMLInputElement>('#user_name')?.setAttribute('disabled', 'disabled');
}
document.querySelector<HTMLInputElement>('#login_name')?.setAttribute('required', 'required');
showElem('.non-local');
hideElem('.local');
document.querySelector<HTMLInputElement>('#login_name')?.focus();
document.querySelector<HTMLInputElement>('#password')?.removeAttribute('required');
const syncFields = (focusField: boolean) => {
const isBot = elUserType?.value === 'Bot';
const isLocal = !isBot && elLoginType.value.startsWith('0'); // login type 0 is a local account without auth source
toggleElem('.js-non-bot', !isBot);
if (!isBot) {
toggleElem('.js-local', isLocal);
toggleElem('.js-non-local', !isLocal);
}
});
elLoginName.toggleAttribute('required', !isBot && !isLocal);
if (isNew) {
elPassword.toggleAttribute('required', isLocal);
} else {
elUserName.toggleAttribute('disabled', !isBot && !isLocal);
}
if (focusField) (isBot || isLocal ? elUserName : elLoginName).focus();
};
elUserType?.addEventListener('change', () => syncFields(true));
elLoginType.addEventListener('change', () => syncFields(true));
if (isNew) syncFields(false);
}
function initAdminAuthentication() {
+1 -1
View File
@@ -22,7 +22,7 @@ export function initAdminUserListSearchForm(): void {
});
}
const resetButtons = form.querySelectorAll<HTMLAnchorElement>('.j-reset-status-filter');
const resetButtons = form.querySelectorAll<HTMLAnchorElement>('.js-reset-status-filter');
for (const button of resetButtons) {
button.addEventListener('click', (e) => {
e.preventDefault();