feat(users): implement comprehensive user listing and control

This commit is contained in:
2026-04-30 21:45:09 +03:00
parent 89c885f637
commit 82d4dc815b
14 changed files with 3170 additions and 97 deletions

View File

@@ -8,17 +8,31 @@ import (
const AdminTagName = "admin"
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
PasswordHash string `json:"password_hash"`
TagIDs []string `json:"tag_ids"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Disabled bool `json:"disabled"`
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
MaxExpirySeconds *int64 `json:"max_expiry_seconds,omitempty"`
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
PasswordHash string `json:"password_hash"`
TagIDs []string `json:"tag_ids"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Disabled bool `json:"disabled"`
AdminNote string `json:"admin_note,omitempty"`
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
MaxExpirySeconds *int64 `json:"max_expiry_seconds,omitempty"`
PermOverrides *UserPermOverrides `json:"perm_overrides,omitempty"`
}
type UserPermOverrides struct {
UploadAllowed *bool `json:"upload_allowed,omitempty"`
ManageOwnBoxes *bool `json:"manage_own_boxes,omitempty"`
ZipDownloadAllowed *bool `json:"zip_download_allowed,omitempty"`
OneTimeDownloadAllowed *bool `json:"one_time_download_allowed,omitempty"`
RenewableAllowed *bool `json:"renewable_allowed,omitempty"`
AllowPasswordProtected *bool `json:"allow_password_protected,omitempty"`
RenewOnAccess *bool `json:"renew_on_access,omitempty"`
RenewOnDownload *bool `json:"renew_on_download,omitempty"`
AllowOwnerBoxEditing *bool `json:"allow_owner_box_editing,omitempty"`
}
type Tag struct {
@@ -42,6 +56,7 @@ type TagPermissions struct {
RenewOnAccessSeconds int64 `json:"renew_on_access_seconds,omitempty"`
RenewOnDownloadSeconds int64 `json:"renew_on_download_seconds,omitempty"`
AdminAccess bool `json:"admin_access"`
AdminUsersView bool `json:"admin_users_view"`
AdminUsersManage bool `json:"admin_users_manage"`
AdminSettingsManage bool `json:"admin_settings_manage"`
AdminBoxesView bool `json:"admin_boxes_view"`
@@ -67,6 +82,7 @@ type EffectivePermissions struct {
RenewOnAccessSeconds int64
RenewOnDownloadSeconds int64
AdminAccess bool
AdminUsersView bool
AdminUsersManage bool
AdminSettingsManage bool
AdminBoxesView bool
@@ -152,3 +168,75 @@ type BoxRecordPage struct {
NextPage int
TotalPages int
}
type UserFilters struct {
Query string
Status string
Role string
Sort string
}
type UserPageRequest struct {
Page int
PageSize int
}
type UserRow struct {
ID string
Username string
Email string
Status string
Role string
TagIDs []string
Tags string
Plan string
PolicySummary string
BoxCount int
APIKeyCount int
CreatedAt string
LastSeen string
Disabled bool
IsCurrent bool
IsInvite bool
}
type UserPage struct {
Rows []UserRow
Page int
PageSize int
Total int
HasPrev bool
HasNext bool
PrevPage int
NextPage int
TotalPages int
Stats UserPageStats
}
type UserPageStats struct {
TotalUsers int
ActiveUsers int
PendingInvites int
DisabledUsers int
}
type CreateUserInput struct {
Username string
Email string
Password string
Mode string
Role string
Plan string
AdminNote string
SendSetup bool
ForceChange bool
}
type CreateUserResult struct {
User User
InviteToken string
InviteLink string
IsInvite bool
PasswordSet string
InviteNotSent bool
}

View File

@@ -22,6 +22,7 @@ func ResolveUserPermissions(cfg *config.Config, user User, tags []Tag) Effective
perms.ZipDownloadAllowed = perms.ZipDownloadAllowed || tagPerms.ZipDownloadAllowed
perms.RenewableAllowed = perms.RenewableAllowed || tagPerms.RenewableAllowed
perms.AdminAccess = perms.AdminAccess || tagPerms.AdminAccess
perms.AdminUsersView = perms.AdminUsersView || tagPerms.AdminUsersView
perms.AdminUsersManage = perms.AdminUsersManage || tagPerms.AdminUsersManage
perms.AdminSettingsManage = perms.AdminSettingsManage || tagPerms.AdminSettingsManage
perms.AdminBoxesView = perms.AdminBoxesView || tagPerms.AdminBoxesView
@@ -50,6 +51,21 @@ func ResolveUserPermissions(cfg *config.Config, user User, tags []Tag) Effective
perms.MaxExpirySeconds = *user.MaxExpirySeconds
}
if o := user.PermOverrides; o != nil {
if o.UploadAllowed != nil {
perms.UploadAllowed = *o.UploadAllowed
}
if o.ZipDownloadAllowed != nil {
perms.ZipDownloadAllowed = *o.ZipDownloadAllowed
}
if o.OneTimeDownloadAllowed != nil {
perms.OneTimeDownloadAllowed = *o.OneTimeDownloadAllowed
}
if o.RenewableAllowed != nil {
perms.RenewableAllowed = *o.RenewableAllowed
}
}
perms.MaxFileSizeBytes = capLimit(perms.MaxFileSizeBytes, cfg.GlobalMaxFileSizeBytes)
perms.MaxBoxSizeBytes = capLimit(perms.MaxBoxSizeBytes, cfg.GlobalMaxBoxSizeBytes)
perms.AllowedExpirySeconds = sortedExpirySet(expirySet)

View File

@@ -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 {

View File

@@ -22,6 +22,7 @@ func AdminPermissions() TagPermissions {
ZipDownloadAllowed: true,
RenewableAllowed: true,
AdminAccess: true,
AdminUsersView: true,
AdminUsersManage: true,
AdminSettingsManage: true,
AdminBoxesView: true,