diff --git a/api.go b/api.go index 9942333..2269581 100644 --- a/api.go +++ b/api.go @@ -418,6 +418,7 @@ func (app *App) APIUpdateUser() func(c echo.Context) error { } updatedUser, err := app.UpdateUser( + app.DB, caller, targetUser, // user req.Password, @@ -425,6 +426,7 @@ func (app *App) APIUpdateUser() func(c echo.Context) error { req.IsLocked, req.ResetAPIToken, req.PreferredLanguage, + nil, ) if err != nil { return err @@ -460,6 +462,7 @@ func (app *App) APIUpdateSelf() func(c echo.Context) error { } updatedUser, err := app.UpdateUser( + app.DB, user, *user, req.Password, @@ -467,6 +470,7 @@ func (app *App) APIUpdateSelf() func(c echo.Context) error { req.IsLocked, req.ResetAPIToken, req.PreferredLanguage, + nil, ) if err != nil { return err diff --git a/front.go b/front.go index 8ab71b2..470bce0 100644 --- a/front.go +++ b/front.go @@ -13,6 +13,7 @@ import ( "net/http" "net/url" "path" + "strconv" ) /* @@ -388,23 +389,48 @@ func FrontUpdateUsers(app *App) func(c echo.Context) error { defer tx.Rollback() anyUnlockedAdmins := false - for _, user := range users { - shouldBeAdmin := c.FormValue("admin-"+user.Username) == "on" - if app.IsDefaultAdmin(&user) { + for _, targetUser := range users { + shouldBeAdmin := c.FormValue("admin-"+targetUser.UUID) == "on" + if app.IsDefaultAdmin(&targetUser) { shouldBeAdmin = true } - shouldBeLocked := c.FormValue("locked-"+user.Username) == "on" + shouldBeLocked := c.FormValue("locked-"+targetUser.UUID) == "on" if shouldBeAdmin && !shouldBeLocked { anyUnlockedAdmins = true } - if user.IsAdmin != shouldBeAdmin || user.IsLocked != shouldBeLocked { - user.IsAdmin = shouldBeAdmin - err := app.SetIsLocked(tx, &user, shouldBeLocked) + + maxPlayerCountString := c.FormValue("max-player-count-" + targetUser.UUID) + maxPlayerCount := targetUser.MaxPlayerCount + if maxPlayerCountString == "" { + maxPlayerCount = app.Constants.MaxPlayerCountUseDefault + } else { + var err error + maxPlayerCount, err = strconv.Atoi(maxPlayerCountString) if err != nil { + return NewWebError(returnURL, "Max player count must be an integer.") + } + } + + if targetUser.IsAdmin != shouldBeAdmin || targetUser.IsLocked != shouldBeLocked || targetUser.MaxPlayerCount != maxPlayerCount { + _, err := app.UpdateUser( + tx, + user, // caller + targetUser, // user + nil, + &shouldBeAdmin, // isAdmin + &shouldBeLocked, // isLocked + false, + nil, + &maxPlayerCount, + ) + if err != nil { + var userError *UserError + if errors.As(err, &userError) { + return &WebError{ReturnURL: returnURL, Err: userError.Err} + } return err } - tx.Save(user) } } @@ -412,7 +438,10 @@ func FrontUpdateUsers(app *App) func(c echo.Context) error { return NewWebError(returnURL, "There must be at least one unlocked admin account.") } - tx.Commit() + err := tx.Commit().Error + if err != nil { + return err + } setSuccessMessage(&c, "Changes saved.") return c.Redirect(http.StatusSeeOther, returnURL) @@ -583,6 +612,7 @@ func FrontUpdateUser(app *App) func(c echo.Context) error { password := nilIfEmpty(c.FormValue("password")) resetAPIToken := c.FormValue("resetApiToken") == "on" preferredLanguage := nilIfEmpty(c.FormValue("preferredLanguage")) + // maxPlayerCount := c.FormValue("maxPlayerCount") var targetUser *User if targetUUID == nil || *targetUUID == user.UUID { @@ -600,6 +630,7 @@ func FrontUpdateUser(app *App) func(c echo.Context) error { } _, err := app.UpdateUser( + app.DB, user, // caller *targetUser, // user password, @@ -607,6 +638,7 @@ func FrontUpdateUser(app *App) func(c echo.Context) error { nil, // isLocked resetAPIToken, preferredLanguage, + nil, ) if err != nil { var userError *UserError @@ -1021,19 +1053,21 @@ func FrontDeleteUser(app *App) func(c echo.Context) error { returnURL := getReturnURL(app, &c) var targetUser *User - targetUsername := c.FormValue("username") - if targetUsername == "" || targetUsername == user.Username { + targetUUID := c.FormValue("uuid") + if targetUUID == "" || targetUUID == user.UUID { targetUser = user } else { if !user.IsAdmin { return NewWebError(app.FrontEndURL, "You are not an admin.") } var targetUserStruct User - result := app.DB.First(&targetUserStruct, "username = ?", targetUsername) - targetUser = &targetUserStruct - if result.Error != nil { - return NewWebError(returnURL, "User not found.") + if err := app.DB.First(&targetUserStruct, "uuid = ?", targetUUID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return NewWebError(returnURL, "User not found.") + } + return err } + targetUser = &targetUserStruct } err := app.DeleteUser(targetUser) diff --git a/front_test.go b/front_test.go index 5fb2941..f0f75a5 100644 --- a/front_test.go +++ b/front_test.go @@ -1296,10 +1296,10 @@ func (ts *TestSuite) testAdmin(t *testing.T) { user, browserTokenCookie := ts.CreateTestUser(ts.App, ts.Server, username) otherUsername := "adminOther" - _, otherBrowserTokenCookie := ts.CreateTestUser(ts.App, ts.Server, otherUsername) + otherUser, otherBrowserTokenCookie := ts.CreateTestUser(ts.App, ts.Server, otherUsername) anotherUsername := "adminAnother" - _, _ = ts.CreateTestUser(ts.App, ts.Server, anotherUsername) + anotherUser, anotherBrowserTokenCookie := ts.CreateTestUser(ts.App, ts.Server, anotherUsername) // Make `username` an admin user.IsAdmin = true @@ -1317,37 +1317,51 @@ func (ts *TestSuite) testAdmin(t *testing.T) { assert.Equal(t, returnURL, rec.Header().Get("Location")) } - // Make `otherUsername` and `anotherUsername` admins and lock their accounts + // Make `otherUser` and `anotherUser` admins, lock their accounts, and set max player counts form := url.Values{} form.Set("returnUrl", returnURL) - form.Set("admin-"+username, "on") - form.Set("admin-"+otherUsername, "on") - form.Set("locked-"+otherUsername, "on") - form.Set("admin-"+anotherUsername, "on") - form.Set("locked-"+anotherUsername, "on") + form.Set("admin-"+user.UUID, "on") + form.Set("admin-"+otherUser.UUID, "on") + form.Set("locked-"+otherUser.UUID, "on") + form.Set("admin-"+anotherUser.UUID, "on") + form.Set("locked-"+anotherUser.UUID, "on") + form.Set("max-player-count-"+otherUser.UUID, "3") + form.Set("max-player-count-"+anotherUser.UUID, "-1") rec := ts.PostForm(t, ts.Server, "/web/admin/update-users", form, []http.Cookie{*browserTokenCookie}, nil) assert.Equal(t, http.StatusSeeOther, rec.Code) assert.Equal(t, "", getErrorMessage(rec)) assert.Equal(t, returnURL, rec.Header().Get("Location")) - // Check that their account was locked and they were made an admin - var other User - result = ts.App.DB.First(&other, "username = ?", otherUsername) + result = ts.App.DB.First(&otherUser, "uuid = ?", otherUser.UUID) assert.Nil(t, result.Error) - assert.True(t, other.IsAdmin) - assert.True(t, other.IsLocked) + assert.True(t, otherUser.IsAdmin) + assert.True(t, otherUser.IsLocked) + assert.Equal(t, 3, otherUser.MaxPlayerCount) // `otherUser` should be logged out of the web interface assert.NotEqual(t, "", otherBrowserTokenCookie.Value) - assert.Nil(t, UnmakeNullString(&other.BrowserToken)) + assert.Nil(t, UnmakeNullString(&otherUser.BrowserToken)) + + result = ts.App.DB.First(&anotherUser, "uuid = ?", anotherUser.UUID) + assert.Nil(t, result.Error) + assert.True(t, anotherUser.IsAdmin) + assert.True(t, anotherUser.IsLocked) + assert.Equal(t, -1, anotherUser.MaxPlayerCount) + // `anotherUser` should be logged out of the web interface + assert.NotEqual(t, "", anotherBrowserTokenCookie.Value) + assert.Nil(t, UnmakeNullString(&anotherUser.BrowserToken)) // Delete `otherUser` form = url.Values{} form.Set("returnUrl", returnURL) - form.Set("username", otherUsername) + form.Set("uuid", otherUser.UUID) rec = ts.PostForm(t, ts.Server, "/web/delete-user", form, []http.Cookie{*browserTokenCookie}, nil) assert.Equal(t, http.StatusSeeOther, rec.Code) assert.Equal(t, "", getErrorMessage(rec)) assert.Equal(t, returnURL, rec.Header().Get("Location")) + + err := ts.App.DB.First(&otherUser, "uuid = ?", otherUser.UUID).Error + assert.NotNil(t, err) + assert.True(t, errors.Is(err, gorm.ErrRecordNotFound)) } diff --git a/model.go b/model.go index 6167070..7957e4e 100644 --- a/model.go +++ b/model.go @@ -100,6 +100,13 @@ func (app *App) ValidatePlayerNameOrUUID(player string) error { return nil } +func (app *App) ValidateMaxPlayerCount(maxPlayerCount int) error { + if maxPlayerCount < 0 && maxPlayerCount != app.Constants.MaxPlayerCountUnlimited && maxPlayerCount != app.Constants.MaxPlayerCountUseDefault { + return errors.New("must be greater than 0, OR use -1 to indicate unlimited players, OR use -2 to use the system default") + } + return nil +} + // func MakeTransientUser(app *App, playerName string) (User, error) { // preimage := bytes.Join([][]byte{ // []byte("uuid"), diff --git a/public/style.css b/public/style.css index eca423d..0e8f39c 100644 --- a/public/style.css +++ b/public/style.css @@ -32,16 +32,17 @@ hr { table { width: 100%; + border-collapse: collapse; +} + +td:not(:first-child) { + padding-left: 0.5rem; } thead { font-weight: bold; } -td:last-of-type { - text-align: right; -} - a { color: var(--accent-lighter); } @@ -96,19 +97,27 @@ summary { cursor: pointer; } +input { + color: white; + accent-color: var(--accent); +} + +input:disabled { + color: gray; + filter: grayscale(100%); +} + input[type="text"], +input[type="number"], input[type="password"] { margin: 0.5em 0; background-color: black; - color: white; padding: 0.5em; - border: none; border: var(--input-border-width) solid var(--accent); } -input[type="checkbox"], -input[type="radio"] { - accent-color: var(--accent); +input[type="number"] { + width: 3rem; } input[type="checkbox"]:not(:disabled), diff --git a/user.go b/user.go index 4247688..ed3ed00 100644 --- a/user.go +++ b/user.go @@ -273,6 +273,7 @@ func (app *App) CreateUser( } func (app *App) UpdateUser( + db *gorm.DB, caller *User, user User, password *string, @@ -280,6 +281,7 @@ func (app *App) UpdateUser( isLocked *bool, resetAPIToken bool, preferredLanguage *string, + maxPlayerCount *int, ) (User, error) { if caller == nil { return User{}, NewBadRequestUserError("Caller cannot be null.") @@ -316,13 +318,6 @@ func (app *App) UpdateUser( user.IsAdmin = *isAdmin } - if isLocked != nil { - if !callerIsAdmin { - return User{}, NewBadRequestUserError("Cannot change locked status of user without having admin privileges yourself.") - } - user.IsLocked = *isLocked - } - if preferredLanguage != nil { if !IsValidPreferredLanguage(*preferredLanguage) { return User{}, NewBadRequestUserError("Invalid preferred language.") @@ -338,7 +333,28 @@ func (app *App) UpdateUser( user.APIToken = apiToken } - if err := app.DB.Save(&user).Error; err != nil { + if maxPlayerCount != nil { + err := app.ValidateMaxPlayerCount(*maxPlayerCount) + if err != nil { + return User{}, NewBadRequestUserError("Invalid max player count: %s", err) + } + user.MaxPlayerCount = *maxPlayerCount + } + + err := db.Transaction(func(tx *gorm.DB) error { + if isLocked != nil { + if !callerIsAdmin { + return NewBadRequestUserError("Cannot change locked status of user without having admin privileges yourself.") + } + app.SetIsLocked(tx, &user, *isLocked) + } + + if err := tx.Save(&user).Error; err != nil { + return err + } + return nil + }) + if err != nil { return User{}, err } @@ -349,11 +365,9 @@ func (app *App) SetIsLocked(db *gorm.DB, user *User, isLocked bool) error { user.IsLocked = isLocked if isLocked { user.BrowserToken = MakeNullString(nil) - for _, player := range user.Players { - err := app.InvalidatePlayer(db, &player) - if err != nil { - return err - } + err := app.InvalidateUser(db, user) + if err != nil { + return err } } return nil diff --git a/view/admin.tmpl b/view/admin.tmpl index bff2ce3..9e16e65 100644 --- a/view/admin.tmpl +++ b/view/admin.tmpl @@ -61,19 +61,18 @@ No invites to show. {{ end }} -
*Specify -1 to allow an unlimited number of players. Leave blank to use the default max number, which is {{ $.App.Config.DefaultMaxPlayerCount }}.
diff --git a/view/layout.tmpl b/view/layout.tmpl index 88d0a27..6896035 100644 --- a/view/layout.tmpl +++ b/view/layout.tmpl @@ -4,7 +4,7 @@
- + {{ if or (lt (len .TargetUser.Players) .MaxPlayerCount) (lt .MaxPlayerCount 0) .AdminView }}