mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-20 03:33:39 +09:00
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:
co-authored by
Nicolas
joestump
Joe Stump
silverwind
Lunny Xiao
parent
db7dbd5a6b
commit
3bec08f998
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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}.")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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/")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -59,6 +59,9 @@ type swaggerParameterBodies struct {
|
||||
// in:body
|
||||
RenameUserOption api.RenameUserOption
|
||||
|
||||
// in:body
|
||||
ConvertUserTypeOption api.ConvertUserTypeOption
|
||||
|
||||
// in:body
|
||||
CreateLabelOption api.CreateLabelOption
|
||||
// in:body
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 @@
|
||||
<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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 @@
|
||||
<a class="tw-font-semibold"{{if gt .ID 0}} href="{{.HomeLink}}"{{end}}>{{.GetDisplayName}}</a>{{if .IsTypeBot}} <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 @@
|
||||
<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 @@
|
||||
<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
@@ -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
@@ -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,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" .}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user