diff --git a/models/user/search.go b/models/user/search.go
index 7f835dc3ff7..8520f8c9fd4 100644
--- a/models/user/search.go
+++ b/models/user/search.go
@@ -40,8 +40,8 @@ type SearchUserOptions struct {
Keyword string
Types []UserType
UID int64
- LoginName string // this option should be used only for admin user
- SourceID int64 // this option should be used only for admin user
+ LoginName string // this option should be used only for admin user
+ SourceID optional.Option[int64] // this option should be used only for admin user, Some(0) means local users
OrderBy db.SearchOrderBy
Visible []structs.VisibleType
Actor *User // The user doing the search
@@ -106,8 +106,8 @@ func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) db.Session
cond = cond.And(builder.Eq{"id": opts.UID})
}
- if opts.SourceID > 0 {
- cond = cond.And(builder.Eq{"login_source": opts.SourceID})
+ if opts.SourceID.Has() {
+ cond = cond.And(builder.Eq{"login_source": opts.SourceID.Value()})
}
if opts.LoginName != "" {
cond = cond.And(builder.Eq{"login_name": opts.LoginName})
diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go
index 8c3a4264a03..0a09493b983 100644
--- a/routers/api/v1/admin/user.go
+++ b/routers/api/v1/admin/user.go
@@ -400,7 +400,7 @@ func SearchUsers(ctx *context.APIContext) {
// parameters:
// - name: source_id
// in: query
- // description: ID of the user's login source to search for
+ // description: ID of the user's login source to search for, 0 means the local users
// type: integer
// format: int64
// - name: login_name
@@ -483,7 +483,7 @@ func SearchUsers(ctx *context.APIContext) {
Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeIndividual},
LoginName: ctx.FormTrim("login_name"),
- SourceID: ctx.FormInt64("source_id"),
+ SourceID: ctx.FormOptionalInt64("source_id"),
Keyword: ctx.FormTrim("q"),
Visible: visible,
OrderBy: orderBy,
diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go
index 6ced93e9e0a..ed48c3d5683 100644
--- a/routers/web/admin/users.go
+++ b/routers/web/admin/users.go
@@ -48,6 +48,13 @@ const (
// UserSearchDefaultAdminSort is the default sort type for admin view
const UserSearchDefaultAdminSort = "alphabetically"
+// authSourceFilterOption is one radio item of the authentication source filter dropdown
+type authSourceFilterOption struct {
+ Value string
+ Label string
+ Selected bool
+}
+
// Users show all the users
func Users(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.users")
@@ -76,6 +83,30 @@ func Users(ctx *context.Context) {
"SortType": sortType,
}
+ // inactive sources are listed too, users stay attached to a source after it is deactivated
+ sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{})
+ if err != nil {
+ ctx.ServerError("auth.Sources", err)
+ return
+ }
+ sourceIDFilter := ctx.FormOptionalInt64("source_id")
+ sourceNames := make(map[int64]string, len(sources))
+ authSourceFilterOptions := []*authSourceFilterOption{
+ {Value: "", Label: ctx.Locale.TrString("all"), Selected: !sourceIDFilter.Has()},
+ {Value: "0", Label: ctx.Locale.TrString("admin.users.local"), Selected: sourceIDFilter.Has() && sourceIDFilter.Value() == 0},
+ }
+ for _, source := range sources {
+ sourceNames[source.ID] = source.Name
+ authSourceFilterOptions = append(authSourceFilterOptions, &authSourceFilterOption{
+ Value: strconv.FormatInt(source.ID, 10),
+ Label: source.Name,
+ Selected: sourceIDFilter.Has() && sourceIDFilter.Value() == source.ID,
+ })
+ }
+ ctx.Data["HasAuthSources"] = len(sources) > 0
+ ctx.Data["SourceNames"] = sourceNames
+ ctx.Data["AuthSourceFilterOptions"] = authSourceFilterOptions
+
explore.RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Types: types,
@@ -88,6 +119,7 @@ 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"]),
+ SourceID: sourceIDFilter,
OrderBy: db.SearchOrderBy(sortType),
}, tplUsers)
}
diff --git a/templates/admin/user/list.tmpl b/templates/admin/user/list.tmpl
index e4ad330c029..2dda8c6db48 100644
--- a/templates/admin/user/list.tmpl
+++ b/templates/admin/user/list.tmpl
@@ -47,6 +47,20 @@
+
+ {{if .HasAuthSources}}
+
+ {{ctx.Locale.Tr "admin.users.auth_source"}}
+ {{svg "octicon-triangle-down" 14 "dropdown icon"}}
+
+
+ {{end}}
+
@@ -75,6 +89,7 @@
{{SortArrow "alphabetically" "reversealphabetically" $.SortType true}}
{{ctx.Locale.Tr "email"}} |
+ {{ctx.Locale.Tr "admin.users.auth_source"}} |
{{ctx.Locale.Tr "admin.users.activated"}} |
{{ctx.Locale.Tr "admin.users.restricted"}} |
{{ctx.Locale.Tr "admin.users.2fa"}} |
@@ -102,6 +117,7 @@
{{template "shared/user/user_type_label" .}}
{{.Email}} |
+ {{if .LoginSource}}{{index $.SourceNames .LoginSource}}{{else}}{{ctx.Locale.Tr "admin.users.local"}}{{end}} |
{{svg (Iif .IsActive "octicon-check" "octicon-x")}} |
{{svg (Iif .IsRestricted "octicon-check" "octicon-x")}} |
{{svg (Iif (index $.UsersTwoFaStatus .ID) "octicon-check" "octicon-x")}} |
@@ -119,7 +135,7 @@
{{else}}
- | {{ctx.Locale.Tr "no_results_found"}} |
+ | {{ctx.Locale.Tr "no_results_found"}} |
{{end}}
diff --git a/templates/swagger/v1-openapi3.generated.json b/templates/swagger/v1-openapi3.generated.json
index c1ac350b400..b92a5a134c6 100644
--- a/templates/swagger/v1-openapi3.generated.json
+++ b/templates/swagger/v1-openapi3.generated.json
@@ -11939,7 +11939,7 @@
"operationId": "adminSearchUsers",
"parameters": [
{
- "description": "ID of the user's login source to search for",
+ "description": "ID of the user's login source to search for, 0 means the local users",
"in": "query",
"name": "source_id",
"schema": {
diff --git a/templates/swagger/v1-swagger.generated.json b/templates/swagger/v1-swagger.generated.json
index f7128397a3e..7ef18edf193 100644
--- a/templates/swagger/v1-swagger.generated.json
+++ b/templates/swagger/v1-swagger.generated.json
@@ -825,7 +825,7 @@
{
"type": "integer",
"format": "int64",
- "description": "ID of the user's login source to search for",
+ "description": "ID of the user's login source to search for, 0 means the local users",
"name": "source_id",
"in": "query"
},
diff --git a/tests/integration/admin_user_test.go b/tests/integration/admin_user_test.go
index 67f5bbdf56b..3b79de1b32f 100644
--- a/tests/integration/admin_user_test.go
+++ b/tests/integration/admin_user_test.go
@@ -16,8 +16,10 @@ import (
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
+ "gitea.dev/services/auth/source/ldap"
"gitea.dev/tests"
+ "github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -34,6 +36,49 @@ func TestAdminViewUsers(t *testing.T) {
session.MakeRequest(t, req, http.StatusForbidden)
}
+func TestAdminViewUsersFilterAuthSource(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ source := &auth_model.Source{Type: auth_model.LDAP, Name: "test-user-list-filter", IsActive: false, Cfg: &ldap.Source{}} // users stay attached to a deactivated source
+ require.NoError(t, auth_model.CreateSource(t.Context(), source))
+
+ user2 := &user_model.User{ID: 2, LoginType: auth_model.LDAP, LoginSource: source.ID}
+ require.NoError(t, user_model.UpdateUserCols(t.Context(), user2, "login_type", "login_source"))
+
+ session := loginUser(t, "user1")
+ listUsers := func(query string) (*HTMLDoc, []string) {
+ req := NewRequest(t, "GET", "/-/admin/users?"+query)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ return doc, doc.Find("table tbody tr td:nth-child(2) a").Map(func(_ int, s *goquery.Selection) string {
+ return s.Text()
+ })
+ }
+
+ doc, users := listUsers("source_id=") // the "All" option submits an empty value
+ AssertHTMLElement(t, doc, `input[name="source_id"][value=""][checked]`, true)
+ assert.Subset(t, users, []string{"user1", "user2"})
+
+ doc, users = listUsers(fmt.Sprintf("source_id=%d", source.ID)) // the "test-user-list-filter" LDAP source
+ AssertHTMLElement(t, doc, fmt.Sprintf(`input[name="source_id"][value="%d"][checked]`, source.ID), true)
+ assert.Equal(t, []string{"user2"}, users)
+ assert.Equal(t, source.Name, doc.Find("table tbody tr td:nth-child(4)").Text())
+
+ _, users = listUsers("source_id=0") // 0 means the "Local" source
+ assert.Contains(t, users, "user1")
+ assert.NotContains(t, users, "user2")
+
+ token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadAdmin)
+ req := NewRequest(t, "GET", "/api/v1/admin/users?source_id=0").AddTokenAuth(token) // the API also treats 0 as local users
+ apiUsers := DecodeJSON(t, MakeRequest(t, req, http.StatusOK), []api.User{})
+ apiUserNames := make([]string, 0, len(apiUsers))
+ for _, u := range apiUsers {
+ apiUserNames = append(apiUserNames, u.UserName)
+ }
+ assert.Contains(t, apiUserNames, "user1")
+ assert.NotContains(t, apiUserNames, "user2")
+}
+
func TestAdminViewUser(t *testing.T) {
defer tests.PrepareTestEnv(t)()