feat(users): implement comprehensive user listing and control
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -291,6 +292,295 @@ func (store *Store) ListUsers() ([]User, error) {
|
||||
return users, err
|
||||
}
|
||||
|
||||
func (store *Store) ListUsersPaginated(filters UserFilters, pageReq UserPageRequest) (UserPage, error) {
|
||||
users, err := store.ListUsers()
|
||||
if err != nil {
|
||||
return UserPage{}, err
|
||||
}
|
||||
|
||||
tags, err := store.ListTags()
|
||||
if err != nil {
|
||||
return UserPage{}, err
|
||||
}
|
||||
tagMap := make(map[string]Tag, len(tags))
|
||||
for _, tag := range tags {
|
||||
tagMap[tag.ID] = tag
|
||||
}
|
||||
|
||||
query := strings.ToLower(strings.TrimSpace(filters.Query))
|
||||
filtered := make([]User, 0, len(users))
|
||||
for _, user := range users {
|
||||
if query != "" {
|
||||
if !strings.Contains(strings.ToLower(user.Username), query) &&
|
||||
!strings.Contains(strings.ToLower(user.Email), query) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
switch filters.Status {
|
||||
case "active":
|
||||
if user.Disabled || strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
continue
|
||||
}
|
||||
case "disabled":
|
||||
if !user.Disabled || strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
continue
|
||||
}
|
||||
case "pending":
|
||||
if !strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if filters.Role != "" && filters.Role != "all" {
|
||||
match := false
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tag, ok := tagMap[tagID]; ok && strings.EqualFold(tag.Name, filters.Role) {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, user)
|
||||
}
|
||||
|
||||
switch filters.Sort {
|
||||
case "createdDesc":
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return filtered[i].CreatedAt.After(filtered[j].CreatedAt)
|
||||
})
|
||||
case "username":
|
||||
fallthrough
|
||||
default:
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return strings.ToLower(filtered[i].Username) < strings.ToLower(filtered[j].Username)
|
||||
})
|
||||
}
|
||||
|
||||
total := len(filtered)
|
||||
pageSize := pageReq.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 12
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
totalPages := (total + pageSize - 1) / pageSize
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
page := pageReq.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
start := (page - 1) * pageSize
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
pageUsers := filtered[start:end]
|
||||
|
||||
stats := UserPageStats{TotalUsers: len(users)}
|
||||
for _, user := range users {
|
||||
if strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
stats.PendingInvites++
|
||||
} else if user.Disabled {
|
||||
stats.DisabledUsers++
|
||||
} else {
|
||||
stats.ActiveUsers++
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]UserRow, len(pageUsers))
|
||||
for i, user := range pageUsers {
|
||||
role := ""
|
||||
tagNames := make([]string, 0, len(user.TagIDs))
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tag, ok := tagMap[tagID]; ok {
|
||||
tagNames = append(tagNames, tag.Name)
|
||||
if tag.Permissions.AdminAccess && role == "" {
|
||||
role = tag.Name
|
||||
} else if role == "" {
|
||||
role = tag.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
plan := "standard"
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tag, ok := tagMap[tagID]; ok && strings.EqualFold(tag.Name, "admin") {
|
||||
plan = "unlimited"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
isInvite := strings.HasPrefix(user.PasswordHash, "invite/")
|
||||
status := userStatus(user.Disabled)
|
||||
if isInvite {
|
||||
status = "pending"
|
||||
}
|
||||
|
||||
rows[i] = UserRow{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Status: status,
|
||||
Role: role,
|
||||
TagIDs: user.TagIDs,
|
||||
Tags: strings.Join(tagNames, ", "),
|
||||
Plan: plan,
|
||||
PolicySummary: "system default",
|
||||
BoxCount: 0,
|
||||
APIKeyCount: 0,
|
||||
CreatedAt: formatTime(user.CreatedAt),
|
||||
LastSeen: "-",
|
||||
Disabled: user.Disabled,
|
||||
IsCurrent: false,
|
||||
IsInvite: isInvite,
|
||||
}
|
||||
}
|
||||
|
||||
return UserPage{
|
||||
Rows: rows,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Total: total,
|
||||
HasPrev: page > 1,
|
||||
HasNext: page < totalPages,
|
||||
PrevPage: page - 1,
|
||||
NextPage: page + 1,
|
||||
TotalPages: totalPages,
|
||||
Stats: stats,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userStatus(disabled bool) string {
|
||||
if disabled {
|
||||
return "disabled"
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func (store *Store) BulkSetUsersDisabled(ids []string, disabled bool) error {
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
for _, id := range ids {
|
||||
var user User
|
||||
if err := getJSON(txn, userKey(id), &user); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
user.Disabled = disabled
|
||||
user.UpdatedAt = time.Now().UTC()
|
||||
if err := putJSON(txn, userKey(id), user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) RevokeUserSessions(userID string) error {
|
||||
tokens, err := store.sessionTokensForUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
for _, token := range tokens {
|
||||
if err := txn.Delete(sessionKey(token)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) BulkRevokeUserSessions(ids []string) error {
|
||||
for _, id := range ids {
|
||||
if err := store.RevokeUserSessions(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) sessionTokensForUser(userID string) ([]string, error) {
|
||||
tokens := []string{}
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = []byte("session/")
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
var session Session
|
||||
if err := it.Item().Value(func(data []byte) error {
|
||||
return json.Unmarshal(data, &session)
|
||||
}); err != nil {
|
||||
continue
|
||||
}
|
||||
if session.UserID == userID {
|
||||
tokens = append(tokens, session.Token)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return tokens, err
|
||||
}
|
||||
|
||||
func (store *Store) CountAdminUsers(adminTagID string) (int, error) {
|
||||
users, err := store.ListUsers()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for _, user := range users {
|
||||
if user.Disabled {
|
||||
continue
|
||||
}
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tagID == adminTagID {
|
||||
count++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (store *Store) CreateUserWithoutPassword(username string, email string, tagIDs []string) (User, error) {
|
||||
hash, err := helpers.RandomHexID(32)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
user := User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: "invite/" + hash,
|
||||
TagIDs: uniqueStrings(tagIDs),
|
||||
Disabled: true,
|
||||
}
|
||||
if err := store.CreateUser(&user); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (store *Store) getUserByIndex(key []byte) (User, bool, error) {
|
||||
var id string
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
|
||||
Reference in New Issue
Block a user