394 lines
9.6 KiB
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)
|
|
}
|