Files
warpbox/lib/server/admin_users.go

394 lines
9.6 KiB
Go

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)
}