package server import ( "crypto/rand" "fmt" "math/big" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "warpbox/lib/metastore" ) 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 } type UserFiltersView struct { Query string Status string Role string Sort string PageSize int } func (app *App) handleAccountUsers(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } perms := currentAccountPermissions(ctx) if !perms.AdminUsersView && !perms.AdminUsersManage { ctx.String(http.StatusForbidden, "Permission denied") return } filters := userFiltersFromRequest(ctx) pageReq := userPageFromRequest(ctx) userPage, err := app.store.ListUsersPaginated(filters, pageReq) if err != nil { ctx.String(http.StatusInternalServerError, "Could not list users") return } 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 } 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) } } 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 } } } } if adminCount-disableAdminCount < 1 { redirectAccountUsers(ctx, "Cannot disable the last active administrator.", "") return } } 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) }