mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-10 06:39:06 +02:00
feat(admin): show and filter users by authentication source (#38900)
This commit is contained in:
1 parent
9b2a3c267b
commit
3d085dbaf1
7 files changed
+102
-9
No files matched your search
@@ -40,8 +40,8 @@ type SearchUserOptions struct {
|
|||||||
Keyword string
|
Keyword string
|
||||||
Types []UserType
|
Types []UserType
|
||||||
UID int64
|
UID int64
|
||||||
LoginName string // this option should be used only for admin user
|
LoginName string // this option should be used only for admin user
|
||||||
SourceID int64 // 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
|
OrderBy db.SearchOrderBy
|
||||||
Visible []structs.VisibleType
|
Visible []structs.VisibleType
|
||||||
Actor *User // The user doing the search
|
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})
|
cond = cond.And(builder.Eq{"id": opts.UID})
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.SourceID > 0 {
|
if opts.SourceID.Has() {
|
||||||
cond = cond.And(builder.Eq{"login_source": opts.SourceID})
|
cond = cond.And(builder.Eq{"login_source": opts.SourceID.Value()})
|
||||||
}
|
}
|
||||||
if opts.LoginName != "" {
|
if opts.LoginName != "" {
|
||||||
cond = cond.And(builder.Eq{"login_name": opts.LoginName})
|
cond = cond.And(builder.Eq{"login_name": opts.LoginName})
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ func SearchUsers(ctx *context.APIContext) {
|
|||||||
// parameters:
|
// parameters:
|
||||||
// - name: source_id
|
// - name: source_id
|
||||||
// in: query
|
// 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
|
// type: integer
|
||||||
// format: int64
|
// format: int64
|
||||||
// - name: login_name
|
// - name: login_name
|
||||||
@@ -483,7 +483,7 @@ func SearchUsers(ctx *context.APIContext) {
|
|||||||
Actor: ctx.Doer,
|
Actor: ctx.Doer,
|
||||||
Types: []user_model.UserType{user_model.UserTypeIndividual},
|
Types: []user_model.UserType{user_model.UserTypeIndividual},
|
||||||
LoginName: ctx.FormTrim("login_name"),
|
LoginName: ctx.FormTrim("login_name"),
|
||||||
SourceID: ctx.FormInt64("source_id"),
|
SourceID: ctx.FormOptionalInt64("source_id"),
|
||||||
Keyword: ctx.FormTrim("q"),
|
Keyword: ctx.FormTrim("q"),
|
||||||
Visible: visible,
|
Visible: visible,
|
||||||
OrderBy: orderBy,
|
OrderBy: orderBy,
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ const (
|
|||||||
// UserSearchDefaultAdminSort is the default sort type for admin view
|
// UserSearchDefaultAdminSort is the default sort type for admin view
|
||||||
const UserSearchDefaultAdminSort = "alphabetically"
|
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
|
// Users show all the users
|
||||||
func Users(ctx *context.Context) {
|
func Users(ctx *context.Context) {
|
||||||
ctx.Data["Title"] = ctx.Tr("admin.users")
|
ctx.Data["Title"] = ctx.Tr("admin.users")
|
||||||
@@ -76,6 +83,30 @@ func Users(ctx *context.Context) {
|
|||||||
"SortType": sortType,
|
"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{
|
explore.RenderUserSearch(ctx, user_model.SearchUserOptions{
|
||||||
Actor: ctx.Doer,
|
Actor: ctx.Doer,
|
||||||
Types: types,
|
Types: types,
|
||||||
@@ -88,6 +119,7 @@ func Users(ctx *context.Context) {
|
|||||||
IsRestricted: optional.ParseBool(statusFilterMap["is_restricted"]),
|
IsRestricted: optional.ParseBool(statusFilterMap["is_restricted"]),
|
||||||
IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]),
|
IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]),
|
||||||
IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]),
|
IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]),
|
||||||
|
SourceID: sourceIDFilter,
|
||||||
OrderBy: db.SearchOrderBy(sortType),
|
OrderBy: db.SearchOrderBy(sortType),
|
||||||
}, tplUsers)
|
}, tplUsers)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Authentication Source Filter Menu Item -->
|
||||||
|
{{if .HasAuthSources}}
|
||||||
|
<div class="ui dropdown type jump item">
|
||||||
|
<span class="text">{{ctx.Locale.Tr "admin.users.auth_source"}}</span>
|
||||||
|
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||||
|
<div class="menu flex-items-menu">
|
||||||
|
{{range $index, $option := .AuthSourceFilterOptions}}
|
||||||
|
{{if eq $index 1}}<div class="divider"></div>{{end}}
|
||||||
|
<label class="item"><input type="radio" name="source_id" value="{{$option.Value}}" {{if $option.Selected}}checked{{end}}> {{$option.Label}}</label>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<!-- Sort Menu Item -->
|
<!-- Sort Menu Item -->
|
||||||
<div class="ui dropdown type jump item">
|
<div class="ui dropdown type jump item">
|
||||||
<span class="text">
|
<span class="text">
|
||||||
@@ -75,6 +89,7 @@
|
|||||||
{{SortArrow "alphabetically" "reversealphabetically" $.SortType true}}
|
{{SortArrow "alphabetically" "reversealphabetically" $.SortType true}}
|
||||||
</th>
|
</th>
|
||||||
<th>{{ctx.Locale.Tr "email"}}</th>
|
<th>{{ctx.Locale.Tr "email"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "admin.users.auth_source"}}</th>
|
||||||
<th>{{ctx.Locale.Tr "admin.users.activated"}}</th>
|
<th>{{ctx.Locale.Tr "admin.users.activated"}}</th>
|
||||||
<th>{{ctx.Locale.Tr "admin.users.restricted"}}</th>
|
<th>{{ctx.Locale.Tr "admin.users.restricted"}}</th>
|
||||||
<th>{{ctx.Locale.Tr "admin.users.2fa"}}</th>
|
<th>{{ctx.Locale.Tr "admin.users.2fa"}}</th>
|
||||||
@@ -102,6 +117,7 @@
|
|||||||
{{template "shared/user/user_type_label" .}}
|
{{template "shared/user/user_type_label" .}}
|
||||||
</td>
|
</td>
|
||||||
<td class="gt-ellipsis tw-max-w-48">{{.Email}}</td>
|
<td class="gt-ellipsis tw-max-w-48">{{.Email}}</td>
|
||||||
|
<td class="gt-ellipsis tw-max-w-32">{{if .LoginSource}}{{index $.SourceNames .LoginSource}}{{else}}{{ctx.Locale.Tr "admin.users.local"}}{{end}}</td>
|
||||||
<td>{{svg (Iif .IsActive "octicon-check" "octicon-x")}}</td>
|
<td>{{svg (Iif .IsActive "octicon-check" "octicon-x")}}</td>
|
||||||
<td>{{svg (Iif .IsRestricted "octicon-check" "octicon-x")}}</td>
|
<td>{{svg (Iif .IsRestricted "octicon-check" "octicon-x")}}</td>
|
||||||
<td>{{svg (Iif (index $.UsersTwoFaStatus .ID) "octicon-check" "octicon-x")}}</td>
|
<td>{{svg (Iif (index $.UsersTwoFaStatus .ID) "octicon-check" "octicon-x")}}</td>
|
||||||
@@ -119,7 +135,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr class="no-results-row"><td class="tw-text-center" colspan="9">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
|
<tr class="no-results-row"><td class="tw-text-center" colspan="10">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+1
-1
@@ -11939,7 +11939,7 @@
|
|||||||
"operationId": "adminSearchUsers",
|
"operationId": "adminSearchUsers",
|
||||||
"parameters": [
|
"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",
|
"in": "query",
|
||||||
"name": "source_id",
|
"name": "source_id",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
+1
-1
@@ -825,7 +825,7 @@
|
|||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64",
|
"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",
|
"name": "source_id",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import (
|
|||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/test"
|
"gitea.dev/modules/test"
|
||||||
|
"gitea.dev/services/auth/source/ldap"
|
||||||
"gitea.dev/tests"
|
"gitea.dev/tests"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -34,6 +36,49 @@ func TestAdminViewUsers(t *testing.T) {
|
|||||||
session.MakeRequest(t, req, http.StatusForbidden)
|
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) {
|
func TestAdminViewUser(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(t)()
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user