From 3d085dbaf1e58eea4d361d75571cfea749f96e91 Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Wed, 30 Sep 2026 11:28:35 -0600 Subject: [PATCH] feat(admin): show and filter users by authentication source (#38900) --- models/user/search.go | 8 ++-- routers/api/v1/admin/user.go | 4 +- routers/web/admin/users.go | 32 ++++++++++++++ templates/admin/user/list.tmpl | 18 +++++++- templates/swagger/v1-openapi3.generated.json | 2 +- templates/swagger/v1-swagger.generated.json | 2 +- tests/integration/admin_user_test.go | 45 ++++++++++++++++++++ 7 files changed, 102 insertions(+), 9 deletions(-) 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}} + + {{end}} +