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( updatedUser, err := app.UpdateUser(
app.DB,
caller, caller,
targetUser, // user targetUser, // user
req.Password, req.Password,
@ -425,6 +426,7 @@ func (app *App) APIUpdateUser() func(c echo.Context) error {
req.IsLocked, req.IsLocked,
req.ResetAPIToken, req.ResetAPIToken,
req.PreferredLanguage, req.PreferredLanguage,
nil,
) )
if err != nil { if err != nil {
return err return err
@ -460,6 +462,7 @@ func (app *App) APIUpdateSelf() func(c echo.Context) error {
} }
updatedUser, err := app.UpdateUser( updatedUser, err := app.UpdateUser(
app.DB,
user, user,
*user, *user,
req.Password, req.Password,
@ -467,6 +470,7 @@ func (app *App) APIUpdateSelf() func(c echo.Context) error {
req.IsLocked, req.IsLocked,
req.ResetAPIToken, req.ResetAPIToken,
req.PreferredLanguage, req.PreferredLanguage,
nil,
) )
if err != nil { if err != nil {
return err return err

View File

@ -13,6 +13,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"strconv"
) )
/* /*
@ -388,23 +389,48 @@ func FrontUpdateUsers(app *App) func(c echo.Context) error {
defer tx.Rollback() defer tx.Rollback()
anyUnlockedAdmins := false anyUnlockedAdmins := false
for _, user := range users { for _, targetUser := range users {
shouldBeAdmin := c.FormValue("admin-"+user.Username) == "on" shouldBeAdmin := c.FormValue("admin-"+targetUser.UUID) == "on"
if app.IsDefaultAdmin(&user) { if app.IsDefaultAdmin(&targetUser) {
shouldBeAdmin = true shouldBeAdmin = true
} }
shouldBeLocked := c.FormValue("locked-"+user.Username) == "on" shouldBeLocked := c.FormValue("locked-"+targetUser.UUID) == "on"
if shouldBeAdmin && !shouldBeLocked { if shouldBeAdmin && !shouldBeLocked {
anyUnlockedAdmins = true anyUnlockedAdmins = true
} }
if user.IsAdmin != shouldBeAdmin || user.IsLocked != shouldBeLocked {
user.IsAdmin = shouldBeAdmin maxPlayerCountString := c.FormValue("max-player-count-" + targetUser.UUID)
err := app.SetIsLocked(tx, &user, shouldBeLocked) maxPlayerCount := targetUser.MaxPlayerCount
if maxPlayerCountString == "" {
maxPlayerCount = app.Constants.MaxPlayerCountUseDefault
} else {
var err error
maxPlayerCount, err = strconv.Atoi(maxPlayerCountString)
if err != nil { 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 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.") 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.") setSuccessMessage(&c, "Changes saved.")
return c.Redirect(http.StatusSeeOther, returnURL) return c.Redirect(http.StatusSeeOther, returnURL)
@ -583,6 +612,7 @@ func FrontUpdateUser(app *App) func(c echo.Context) error {
password := nilIfEmpty(c.FormValue("password")) password := nilIfEmpty(c.FormValue("password"))
resetAPIToken := c.FormValue("resetApiToken") == "on" resetAPIToken := c.FormValue("resetApiToken") == "on"
preferredLanguage := nilIfEmpty(c.FormValue("preferredLanguage")) preferredLanguage := nilIfEmpty(c.FormValue("preferredLanguage"))
// maxPlayerCount := c.FormValue("maxPlayerCount")
var targetUser *User var targetUser *User
if targetUUID == nil || *targetUUID == user.UUID { if targetUUID == nil || *targetUUID == user.UUID {
@ -600,6 +630,7 @@ func FrontUpdateUser(app *App) func(c echo.Context) error {
} }
_, err := app.UpdateUser( _, err := app.UpdateUser(
app.DB,
user, // caller user, // caller
*targetUser, // user *targetUser, // user
password, password,
@ -607,6 +638,7 @@ func FrontUpdateUser(app *App) func(c echo.Context) error {
nil, // isLocked nil, // isLocked
resetAPIToken, resetAPIToken,
preferredLanguage, preferredLanguage,
nil,
) )
if err != nil { if err != nil {
var userError *UserError var userError *UserError
@ -1021,19 +1053,21 @@ func FrontDeleteUser(app *App) func(c echo.Context) error {
returnURL := getReturnURL(app, &c) returnURL := getReturnURL(app, &c)
var targetUser *User var targetUser *User
targetUsername := c.FormValue("username") targetUUID := c.FormValue("uuid")
if targetUsername == "" || targetUsername == user.Username { if targetUUID == "" || targetUUID == user.UUID {
targetUser = user targetUser = user
} else { } else {
if !user.IsAdmin { if !user.IsAdmin {
return NewWebError(app.FrontEndURL, "You are not an admin.") return NewWebError(app.FrontEndURL, "You are not an admin.")
} }
var targetUserStruct User var targetUserStruct User
result := app.DB.First(&targetUserStruct, "username = ?", targetUsername) if err := app.DB.First(&targetUserStruct, "uuid = ?", targetUUID).Error; err != nil {
targetUser = &targetUserStruct if errors.Is(err, gorm.ErrRecordNotFound) {
if result.Error != nil { return NewWebError(returnURL, "User not found.")
return NewWebError(returnURL, "User not found.") }
return err
} }
targetUser = &targetUserStruct
} }
err := app.DeleteUser(targetUser) 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) user, browserTokenCookie := ts.CreateTestUser(ts.App, ts.Server, username)
otherUsername := "adminOther" otherUsername := "adminOther"
_, otherBrowserTokenCookie := ts.CreateTestUser(ts.App, ts.Server, otherUsername) otherUser, otherBrowserTokenCookie := ts.CreateTestUser(ts.App, ts.Server, otherUsername)
anotherUsername := "adminAnother" anotherUsername := "adminAnother"
_, _ = ts.CreateTestUser(ts.App, ts.Server, anotherUsername) anotherUser, anotherBrowserTokenCookie := ts.CreateTestUser(ts.App, ts.Server, anotherUsername)
// Make `username` an admin // Make `username` an admin
user.IsAdmin = true user.IsAdmin = true
@ -1317,37 +1317,51 @@ func (ts *TestSuite) testAdmin(t *testing.T) {
assert.Equal(t, returnURL, rec.Header().Get("Location")) 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 := url.Values{}
form.Set("returnUrl", returnURL) form.Set("returnUrl", returnURL)
form.Set("admin-"+username, "on") form.Set("admin-"+user.UUID, "on")
form.Set("admin-"+otherUsername, "on") form.Set("admin-"+otherUser.UUID, "on")
form.Set("locked-"+otherUsername, "on") form.Set("locked-"+otherUser.UUID, "on")
form.Set("admin-"+anotherUsername, "on") form.Set("admin-"+anotherUser.UUID, "on")
form.Set("locked-"+anotherUsername, "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) 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, http.StatusSeeOther, rec.Code)
assert.Equal(t, "", getErrorMessage(rec)) assert.Equal(t, "", getErrorMessage(rec))
assert.Equal(t, returnURL, rec.Header().Get("Location")) assert.Equal(t, returnURL, rec.Header().Get("Location"))
// Check that their account was locked and they were made an admin result = ts.App.DB.First(&otherUser, "uuid = ?", otherUser.UUID)
var other User
result = ts.App.DB.First(&other, "username = ?", otherUsername)
assert.Nil(t, result.Error) assert.Nil(t, result.Error)
assert.True(t, other.IsAdmin) assert.True(t, otherUser.IsAdmin)
assert.True(t, other.IsLocked) assert.True(t, otherUser.IsLocked)
assert.Equal(t, 3, otherUser.MaxPlayerCount)
// `otherUser` should be logged out of the web interface // `otherUser` should be logged out of the web interface
assert.NotEqual(t, "", otherBrowserTokenCookie.Value) 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` // Delete `otherUser`
form = url.Values{} form = url.Values{}
form.Set("returnUrl", returnURL) 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) rec = ts.PostForm(t, ts.Server, "/web/delete-user", form, []http.Cookie{*browserTokenCookie}, nil)
assert.Equal(t, http.StatusSeeOther, rec.Code) assert.Equal(t, http.StatusSeeOther, rec.Code)
assert.Equal(t, "", getErrorMessage(rec)) assert.Equal(t, "", getErrorMessage(rec))
assert.Equal(t, returnURL, rec.Header().Get("Location")) 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 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) { // func MakeTransientUser(app *App, playerName string) (User, error) {
// preimage := bytes.Join([][]byte{ // preimage := bytes.Join([][]byte{
// []byte("uuid"), // []byte("uuid"),

View File

@ -32,16 +32,17 @@ hr {
table { table {
width: 100%; width: 100%;
border-collapse: collapse;
}
td:not(:first-child) {
padding-left: 0.5rem;
} }
thead { thead {
font-weight: bold; font-weight: bold;
} }
td:last-of-type {
text-align: right;
}
a { a {
color: var(--accent-lighter); color: var(--accent-lighter);
} }
@ -96,19 +97,27 @@ summary {
cursor: pointer; cursor: pointer;
} }
input {
color: white;
accent-color: var(--accent);
}
input:disabled {
color: gray;
filter: grayscale(100%);
}
input[type="text"], input[type="text"],
input[type="number"],
input[type="password"] { input[type="password"] {
margin: 0.5em 0; margin: 0.5em 0;
background-color: black; background-color: black;
color: white;
padding: 0.5em; padding: 0.5em;
border: none;
border: var(--input-border-width) solid var(--accent); border: var(--input-border-width) solid var(--accent);
} }
input[type="checkbox"], input[type="number"] {
input[type="radio"] { width: 3rem;
accent-color: var(--accent);
} }
input[type="checkbox"]:not(:disabled), input[type="checkbox"]:not(:disabled),

40
user.go
View File

@ -273,6 +273,7 @@ func (app *App) CreateUser(
} }
func (app *App) UpdateUser( func (app *App) UpdateUser(
db *gorm.DB,
caller *User, caller *User,
user User, user User,
password *string, password *string,
@ -280,6 +281,7 @@ func (app *App) UpdateUser(
isLocked *bool, isLocked *bool,
resetAPIToken bool, resetAPIToken bool,
preferredLanguage *string, preferredLanguage *string,
maxPlayerCount *int,
) (User, error) { ) (User, error) {
if caller == nil { if caller == nil {
return User{}, NewBadRequestUserError("Caller cannot be null.") return User{}, NewBadRequestUserError("Caller cannot be null.")
@ -316,13 +318,6 @@ func (app *App) UpdateUser(
user.IsAdmin = *isAdmin 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 preferredLanguage != nil {
if !IsValidPreferredLanguage(*preferredLanguage) { if !IsValidPreferredLanguage(*preferredLanguage) {
return User{}, NewBadRequestUserError("Invalid preferred language.") return User{}, NewBadRequestUserError("Invalid preferred language.")
@ -338,7 +333,28 @@ func (app *App) UpdateUser(
user.APIToken = apiToken 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 return User{}, err
} }
@ -349,11 +365,9 @@ func (app *App) SetIsLocked(db *gorm.DB, user *User, isLocked bool) error {
user.IsLocked = isLocked user.IsLocked = isLocked
if isLocked { if isLocked {
user.BrowserToken = MakeNullString(nil) user.BrowserToken = MakeNullString(nil)
for _, player := range user.Players { err := app.InvalidateUser(db, user)
err := app.InvalidatePlayer(db, &player) if err != nil {
if err != nil { return err
return err
}
} }
} }
return nil return nil

View File

@ -61,19 +61,18 @@
No invites to show. No invites to show.
{{ end }} {{ end }}
<h4>All Users</h4> <h4>All Users</h4>
<div style="display: none"> <div style="display: none">
{{ range $user := .Users }} {{ range $user := .Users }}
<form <form
id="delete-{{ $user.Username }}" id="delete-{{ $user.UUID }}"
action="{{ $.App.FrontEndURL }}/web/delete-user" action="{{ $.App.FrontEndURL }}/web/delete-user"
method="post" method="post"
onsubmit="return confirm('Are you sure you want to delete the account “{{ $user.Username }}”? This action is irreversible.');" 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 hidden name="returnUrl" value="{{ $.URL }}" />
<input type="text" name="username" value="{{ $user.Username }}" /> <input hidden type="text" name="uuid" value="{{ $user.UUID }}" />
</form> </form>
{{ end }} {{ end }}
</div> </div>
@ -84,6 +83,7 @@
<tr> <tr>
<td colspan="2">User</td> <td colspan="2">User</td>
<td>Players</td> <td>Players</td>
<td>Max # players*</td>
<td>Admin</td> <td>Admin</td>
<td>Locked</td> <td>Locked</td>
<td>Delete Account</td> <td>Delete Account</td>
@ -113,10 +113,18 @@
{{ len $user.Players }} players {{ len $user.Players }} players
{{ end }} {{ end }}
</td> </td>
{{/*<td>{{ $user.PlayerName }}</td>*/}}
<td> <td>
<input <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?" title="Admin?"
type="checkbox" type="checkbox"
{{ if {{ if
@ -134,7 +142,7 @@
</td> </td>
<td> <td>
<input <input
name="locked-{{ $user.Username }}" name="locked-{{ $user.UUID }}"
title="Locked?" title="Locked?"
type="checkbox" type="checkbox"
{{ if {{ if
@ -147,7 +155,7 @@
<td> <td>
<input <input
type="submit" type="submit"
form="delete-{{ $user.Username }}" form="delete-{{ $user.UUID }}"
value="× Delete" value="× Delete"
/> />
</td> </td>
@ -155,6 +163,7 @@
{{ end }} {{ end }}
</tbody> </tbody>
</table> </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"> <p style="text-align: center">
<input hidden name="returnUrl" value="{{ $.URL }}" /> <input hidden name="returnUrl" value="{{ $.URL }}" />
<input type="submit" value="Save changes" /> <input type="submit" value="Save changes" />

View File

@ -4,7 +4,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <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 <meta
name="description" name="description"
content="A self-hosted API server for Minecraft" 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 }}{{ .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 }} {{ if .AdminView }}You can override this limit since you're an admin.{{ end }}
{{ else if (gt .MaxPlayerCount 0) }} {{ 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 }} {{ if .AdminView }}You can override this limit since you're an admin.{{ end }}
{{ else }} {{ 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 }} {{ end }}
</p> </p>
{{ if or (lt (len .TargetUser.Players) .MaxPlayerCount) (lt .MaxPlayerCount 0) .AdminView }} {{ if or (lt (len .TargetUser.Players) .MaxPlayerCount) (lt .MaxPlayerCount 0) .AdminView }}