Allow setting user's max player count in admin panel

This commit is contained in:
Evan Goode 2024-11-29 14:38:23 -05:00
parent f045b9a57e
commit 1da3ac2601
9 changed files with 153 additions and 62 deletions

4
api.go
View File

@ -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

View File

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

View File

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

View File

@ -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"),

View File

@ -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),

40
user.go
View File

@ -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

View File

@ -61,19 +61,18 @@
No invites to show.
{{ end }}
<h4>All Users</h4>
<div style="display: none">
{{ range $user := .Users }}
<form
id="delete-{{ $user.Username }}"
id="delete-{{ $user.UUID }}"
action="{{ $.App.FrontEndURL }}/web/delete-user"
method="post"
onsubmit="return confirm('Are you sure you want to delete the account “{{ $user.Username }}”? This action is irreversible.');"
>
<input hidden name="returnUrl" value="{{ $.URL }}" />
<input type="text" name="username" value="{{ $user.Username }}" />
<input hidden type="text" name="uuid" value="{{ $user.UUID }}" />
</form>
{{ end }}
</div>
@ -84,6 +83,7 @@
<tr>
<td colspan="2">User</td>
<td>Players</td>
<td>Max # players*</td>
<td>Admin</td>
<td>Locked</td>
<td>Delete Account</td>
@ -113,10 +113,18 @@
{{ len $user.Players }} players
{{ end }}
</td>
{{/*<td>{{ $user.PlayerName }}</td>*/}}
<td>
<input
name="admin-{{ $user.Username }}"
name="max-player-count-{{ $user.UUID }}"
type="number"
{{ if $user.IsAdmin }}disabled{{ end }}
value="{{ if or $user.IsAdmin (eq $user.MaxPlayerCount $.App.Constants.MaxPlayerCountUnlimited) }}-1{{ else if eq $user.MaxPlayerCount $.App.Constants.MaxPlayerCountUseDefault}}{{ else }}{{ $user.MaxPlayerCount }}{{ end }}"
placeholder="{{ $.App.Config.DefaultMaxPlayerCount }}"
min="-1">
</input>
<td>
<input
name="admin-{{ $user.UUID }}"
title="Admin?"
type="checkbox"
{{ if
@ -134,7 +142,7 @@
</td>
<td>
<input
name="locked-{{ $user.Username }}"
name="locked-{{ $user.UUID }}"
title="Locked?"
type="checkbox"
{{ if
@ -147,7 +155,7 @@
<td>
<input
type="submit"
form="delete-{{ $user.Username }}"
form="delete-{{ $user.UUID }}"
value="× Delete"
/>
</td>
@ -155,6 +163,7 @@
{{ end }}
</tbody>
</table>
<p><small>*Specify -1 to allow an unlimited number of players. Leave blank to use the default max number, which is {{ $.App.Config.DefaultMaxPlayerCount }}.</small></p>
<p style="text-align: center">
<input hidden name="returnUrl" value="{{ $.URL }}" />
<input type="submit" value="Save changes" />

View File

@ -4,7 +4,7 @@
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="minimum-scale=1, width=device-width, initial-scale=1.0" />
<meta
name="description"
content="A self-hosted API server for Minecraft"

View File

@ -71,10 +71,10 @@
{{ if .AdminView }}{{ .TargetUser.Username }} is{{ else }}You are{{ end }} not allowed to create players.
{{ if .AdminView }}You can override this limit since you're an admin.{{ end }}
{{ else if (gt .MaxPlayerCount 0) }}
{{ if .AdminView }}{{ .TargetUser.Username }}'s{{ else }}Your{{ end }} account can have up to {{ .MaxPlayerCount }} associated player(s).
{{ if .AdminView }}{{ .TargetUser.Username }}'s{{ else }}Your{{ end }} account can have up to {{ .MaxPlayerCount }} player(s).
{{ if .AdminView }}You can override this limit since you're an admin.{{ end }}
{{ else }}
{{ if .AdminView }}{{ .TargetUser.Username }}'s{{ else }}Your{{ end }} account can have an unlimited number of associated players.
{{ if .AdminView }}{{ .TargetUser.Username }}'s{{ else }}Your{{ end }} account can have an unlimited number of players.
{{ end }}
</p>
{{ if or (lt (len .TargetUser.Players) .MaxPlayerCount) (lt .MaxPlayerCount 0) .AdminView }}