feat(users): implement comprehensive user listing and control
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -10,112 +13,381 @@ import (
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type adminUserRow struct {
|
||||
ID string
|
||||
Username string
|
||||
Email string
|
||||
Tags string
|
||||
CreatedAt string
|
||||
Disabled bool
|
||||
IsCurrent bool
|
||||
const defaultUserPageSize = 12
|
||||
|
||||
type UsersIndexView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
PageScripts []string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Filters UserFiltersView
|
||||
Rows []metastore.UserRow
|
||||
Stats metastore.UserPageStats
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
TotalPages int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
CanManage bool
|
||||
Tags []metastore.Tag
|
||||
Error string
|
||||
Success string
|
||||
}
|
||||
|
||||
func (app *App) handleAdminUsers(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||
return
|
||||
}
|
||||
app.renderAdminUsers(ctx, "")
|
||||
type UserFiltersView struct {
|
||||
Query string
|
||||
Status string
|
||||
Role string
|
||||
Sort string
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func (app *App) handleAdminUsersPost(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||
func (app *App) handleAccountUsers(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.PostForm("action") == "toggle_disabled" {
|
||||
userID := strings.TrimSpace(ctx.PostForm("user_id"))
|
||||
user, ok, err := app.store.GetUser(userID)
|
||||
if err != nil || !ok {
|
||||
app.renderAdminUsers(ctx, "User not found.")
|
||||
return
|
||||
}
|
||||
if current, ok := ctx.Get("adminUser"); ok {
|
||||
if currentUser, ok := current.(metastore.User); ok && currentUser.ID == user.ID {
|
||||
app.renderAdminUsers(ctx, "You cannot disable the user for the active session.")
|
||||
return
|
||||
}
|
||||
}
|
||||
user.Disabled = !user.Disabled
|
||||
if err := app.store.UpdateUser(user); err != nil {
|
||||
app.renderAdminUsers(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersView && !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
username := ctx.PostForm("username")
|
||||
email := ctx.PostForm("email")
|
||||
password := ctx.PostForm("password")
|
||||
tagIDs := ctx.PostFormArray("tag_ids")
|
||||
if _, err := app.store.CreateUserWithPassword(username, email, password, tagIDs); err != nil {
|
||||
app.renderAdminUsers(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
||||
}
|
||||
filters := userFiltersFromRequest(ctx)
|
||||
pageReq := userPageFromRequest(ctx)
|
||||
|
||||
func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) {
|
||||
users, err := app.store.ListUsers()
|
||||
userPage, err := app.store.ListUsersPaginated(filters, pageReq)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not list users")
|
||||
return
|
||||
}
|
||||
tags, err := app.store.ListTags()
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not list tags")
|
||||
|
||||
currentID := actor.ID
|
||||
for i := range userPage.Rows {
|
||||
if userPage.Rows[i].ID == currentID {
|
||||
userPage.Rows[i].IsCurrent = true
|
||||
}
|
||||
}
|
||||
|
||||
tags, _ := app.store.ListTags()
|
||||
|
||||
view := UsersIndexView{
|
||||
PageTitle: "WarpBox Users",
|
||||
WindowTitle: "WarpBox Users",
|
||||
WindowIcon: "U",
|
||||
PageScripts: []string{"/static/js/account-users.js"},
|
||||
AccountNav: app.accountNavView(ctx, "users"),
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Filters: UserFiltersView{
|
||||
Query: filters.Query,
|
||||
Status: filters.Status,
|
||||
Role: filters.Role,
|
||||
Sort: filters.Sort,
|
||||
PageSize: pageReq.PageSize,
|
||||
},
|
||||
Rows: userPage.Rows,
|
||||
Stats: userPage.Stats,
|
||||
Page: userPage.Page,
|
||||
PageSize: userPage.PageSize,
|
||||
Total: userPage.Total,
|
||||
TotalPages: userPage.TotalPages,
|
||||
HasPrev: userPage.HasPrev,
|
||||
HasNext: userPage.HasNext,
|
||||
CanManage: perms.AdminUsersManage,
|
||||
Tags: tags,
|
||||
Error: ctx.Query("error"),
|
||||
Success: ctx.Query("success"),
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "account_users.html", view)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersPost(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
tagNames := make(map[string]string, len(tags))
|
||||
for _, tag := range tags {
|
||||
tagNames[tag.ID] = tag.Name
|
||||
}
|
||||
sort.Slice(users, func(i int, j int) bool {
|
||||
return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username)
|
||||
})
|
||||
|
||||
currentID := ""
|
||||
if current, ok := ctx.Get("adminUser"); ok {
|
||||
if currentUser, ok := current.(metastore.User); ok {
|
||||
currentID = currentUser.ID
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
action := ctx.PostForm("action")
|
||||
switch action {
|
||||
case "create":
|
||||
app.handleAccountUsersCreate(ctx, actor)
|
||||
default:
|
||||
redirectAccountUsers(ctx, "Unknown action", "")
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersCreate(ctx *gin.Context, _ metastore.User) {
|
||||
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||
email := strings.TrimSpace(ctx.PostForm("email"))
|
||||
mode := strings.TrimSpace(ctx.PostForm("mode"))
|
||||
role := strings.TrimSpace(ctx.PostForm("role"))
|
||||
|
||||
if username == "" {
|
||||
redirectAccountUsers(ctx, "Username is required.", "")
|
||||
return
|
||||
}
|
||||
if email == "" {
|
||||
redirectAccountUsers(ctx, "Email is required.", "")
|
||||
return
|
||||
}
|
||||
|
||||
var tagIDs []string
|
||||
if role != "" && role != "all" {
|
||||
tag, ok, err := app.store.GetTagByName(role)
|
||||
if err != nil {
|
||||
redirectAccountUsers(ctx, "Could not look up role.", "")
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
tagIDs = append(tagIDs, tag.ID)
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]adminUserRow, 0, len(users))
|
||||
for _, user := range users {
|
||||
names := make([]string, 0, len(user.TagIDs))
|
||||
for _, tagID := range user.TagIDs {
|
||||
if name := tagNames[tagID]; name != "" {
|
||||
names = append(names, name)
|
||||
switch mode {
|
||||
case "invite":
|
||||
user, err := app.store.CreateUserWithoutPassword(username, email, tagIDs)
|
||||
if err != nil {
|
||||
redirectAccountUsers(ctx, err.Error(), "")
|
||||
return
|
||||
}
|
||||
inviteLink := fmt.Sprintf("/account/setup?token=%s", strings.TrimPrefix(user.PasswordHash, "invite/"))
|
||||
msg := fmt.Sprintf("Invite user created. Setup link: %s (Email delivery not yet implemented.)", inviteLink)
|
||||
redirectAccountUsers(ctx, "", msg)
|
||||
|
||||
case "create":
|
||||
password := strings.TrimSpace(ctx.PostForm("password"))
|
||||
autoGen := false
|
||||
if password == "" {
|
||||
password = randomPassword()
|
||||
autoGen = true
|
||||
}
|
||||
user, err := app.store.CreateUserWithPassword(username, email, password, tagIDs)
|
||||
if err != nil {
|
||||
redirectAccountUsers(ctx, err.Error(), "")
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("User %s created.", user.Username)
|
||||
if autoGen {
|
||||
msg = fmt.Sprintf("User %s created. Temporary password: %s", user.Username, password)
|
||||
}
|
||||
redirectAccountUsers(ctx, "", msg)
|
||||
|
||||
default:
|
||||
redirectAccountUsers(ctx, "Select create or invite mode.", "")
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersBulkDisable(ctx *gin.Context) {
|
||||
app.handleAccountUsersBulkSetDisabled(ctx, true)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersBulkEnable(ctx *gin.Context) {
|
||||
app.handleAccountUsersBulkSetDisabled(ctx, false)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersBulkSetDisabled(ctx *gin.Context, disabled bool) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
ids := parseSelectedIDs(ctx)
|
||||
if len(ids) == 0 {
|
||||
redirectAccountUsers(ctx, "No users selected.", "")
|
||||
return
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
if id == actor.ID {
|
||||
redirectAccountUsers(ctx, "You cannot disable yourself.", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if disabled {
|
||||
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||
if err != nil || !ok {
|
||||
redirectAccountUsers(ctx, "Could not verify admin protection.", "")
|
||||
return
|
||||
}
|
||||
adminCount, err := app.store.CountAdminUsers(adminTag.ID)
|
||||
if err != nil {
|
||||
redirectAccountUsers(ctx, "Could not verify admin protection.", "")
|
||||
return
|
||||
}
|
||||
disableAdminCount := 0
|
||||
for _, id := range ids {
|
||||
user, ok, err := app.store.GetUser(id)
|
||||
if err != nil || !ok {
|
||||
continue
|
||||
}
|
||||
if !user.Disabled {
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tagID == adminTag.ID {
|
||||
disableAdminCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rows = append(rows, adminUserRow{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Tags: strings.Join(names, ", "),
|
||||
CreatedAt: formatAdminTime(user.CreatedAt),
|
||||
Disabled: user.Disabled,
|
||||
IsCurrent: user.ID == currentID,
|
||||
})
|
||||
if adminCount-disableAdminCount < 1 {
|
||||
redirectAccountUsers(ctx, "Cannot disable the last active administrator.", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
|
||||
"AdminSection": "users",
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
"Users": rows,
|
||||
"Tags": tags,
|
||||
"Error": errorMessage,
|
||||
})
|
||||
if err := app.store.BulkSetUsersDisabled(ids, disabled); err != nil {
|
||||
redirectAccountUsers(ctx, "Could not update users.", "")
|
||||
return
|
||||
}
|
||||
|
||||
action := "disabled"
|
||||
if !disabled {
|
||||
action = "enabled"
|
||||
}
|
||||
redirectAccountUsers(ctx, "", fmt.Sprintf("%d user(s) %s.", len(ids), action))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersBulkRevokeSessions(ctx *gin.Context) {
|
||||
_, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
ids := parseSelectedIDs(ctx)
|
||||
if len(ids) == 0 {
|
||||
redirectAccountUsers(ctx, "No users selected.", "")
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.store.BulkRevokeUserSessions(ids); err != nil {
|
||||
redirectAccountUsers(ctx, "Could not revoke sessions.", "")
|
||||
return
|
||||
}
|
||||
|
||||
redirectAccountUsers(ctx, "", fmt.Sprintf("Sessions revoked for %d user(s).", len(ids)))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersResendInvite(ctx *gin.Context) {
|
||||
_, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
if userID == "" {
|
||||
redirectAccountUsers(ctx, "User ID is required.", "")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok, err := app.store.GetUser(userID)
|
||||
if err != nil || !ok {
|
||||
redirectAccountUsers(ctx, "User not found.", "")
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
redirectAccountUsers(ctx, "This user is not a pending invite.", "")
|
||||
return
|
||||
}
|
||||
|
||||
inviteLink := fmt.Sprintf("/account/setup?token=%s", strings.TrimPrefix(user.PasswordHash, "invite/"))
|
||||
redirectAccountUsers(ctx, "", fmt.Sprintf("Invite link: %s (Email delivery not yet implemented.)", inviteLink))
|
||||
}
|
||||
|
||||
func userFiltersFromRequest(ctx *gin.Context) metastore.UserFilters {
|
||||
return metastore.UserFilters{
|
||||
Query: strings.TrimSpace(ctx.Query("q")),
|
||||
Status: strings.TrimSpace(ctx.Query("status")),
|
||||
Role: strings.TrimSpace(ctx.Query("role")),
|
||||
Sort: strings.TrimSpace(ctx.Query("sort")),
|
||||
}
|
||||
}
|
||||
|
||||
func userPageFromRequest(ctx *gin.Context) metastore.UserPageRequest {
|
||||
page := 1
|
||||
if p, err := strconv.Atoi(ctx.Query("page")); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
pageSize := defaultUserPageSize
|
||||
if ps, err := strconv.Atoi(ctx.Query("page_size")); err == nil && ps >= 1 && ps <= 100 {
|
||||
pageSize = ps
|
||||
}
|
||||
return metastore.UserPageRequest{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
func parseSelectedIDs(ctx *gin.Context) []string {
|
||||
raw := ctx.PostForm("selected_ids")
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
id := strings.TrimSpace(part)
|
||||
if id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func redirectAccountUsers(ctx *gin.Context, errorMsg string, successMsg string) {
|
||||
redirectURL := "/account/users"
|
||||
if errorMsg != "" {
|
||||
redirectURL = "/account/users?error=" + errorMsg
|
||||
} else if successMsg != "" {
|
||||
redirectURL = "/account/users?success=" + successMsg
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, redirectURL)
|
||||
}
|
||||
|
||||
func randomPassword() string {
|
||||
const charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
result := make([]byte, 16)
|
||||
for i := range result {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||
if err != nil {
|
||||
return "changeme123"
|
||||
}
|
||||
result[i] = charset[n.Int64()]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user