feat(admin-console): server-rendered operator console at /_gm #87
@@ -381,6 +381,7 @@ func run(ctx context.Context) (err error) {
|
||||
Games: lobbySvc,
|
||||
Runtime: runtimeSvc,
|
||||
EngineVersions: engineVersionSvc,
|
||||
Operators: adminSvc,
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
|
||||
@@ -101,6 +101,10 @@ changes.
|
||||
| `/_gm/games/{id}/runtime/force-next-turn` | POST | Force the next turn now. |
|
||||
| `/_gm/engine-versions` | GET/POST | Version registry; POST registers a version. |
|
||||
| `/_gm/engine-versions/{ver}/disable` | POST | Disable a registered version. |
|
||||
| `/_gm/operators` | GET/POST | Admin-account list; POST creates an operator. |
|
||||
| `/_gm/operators/{user}/disable` | POST | Disable an operator. |
|
||||
| `/_gm/operators/{user}/enable` | POST | Re-enable an operator. |
|
||||
| `/_gm/operators/{user}/reset-password` | POST | Reset an operator's password. |
|
||||
|
||||
Each page reuses the same service layer as the corresponding `/api/v1/admin/*`
|
||||
JSON endpoint; the console adds no business logic. Collection-mutating POSTs are
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package adminconsole
|
||||
|
||||
// OperatorRow is one line in the operators (admin accounts) table.
|
||||
type OperatorRow struct {
|
||||
Username string
|
||||
CreatedAt string
|
||||
LastUsedAt string
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// OperatorsData is the view model for the operators page.
|
||||
type OperatorsData struct {
|
||||
Items []OperatorRow
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{{define "content" -}}
|
||||
{{$csrf := .CSRFToken}}
|
||||
<h1>Operators</h1>
|
||||
{{with .Data}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Username</th><th>Status</th><th>Created</th><th>Last used</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td>{{.Username}}</td>
|
||||
<td>{{if .Disabled}}<span class="bad">disabled</span>{{else}}<span class="ok">active</span>{{end}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
<td>{{if .LastUsedAt}}{{.LastUsedAt}}{{else}}—{{end}}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
{{if .Disabled}}
|
||||
<form method="post" action="/_gm/operators/{{.Username}}/enable"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Enable</button></form>
|
||||
{{else}}
|
||||
<form method="post" action="/_gm/operators/{{.Username}}/disable" onsubmit="return confirm('Disable {{.Username}}?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Disable</button></form>
|
||||
{{end}}
|
||||
<form method="post" action="/_gm/operators/{{.Username}}/reset-password" class="form"><input type="hidden" name="_csrf" value="{{$csrf}}"><input type="password" name="password" placeholder="new password" required><button type="submit">Reset</button></form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no operators</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
<section class="panel">
|
||||
<h2>Create operator</h2>
|
||||
<form method="post" action="/_gm/operators" class="form">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<label>Username <input type="text" name="username" required></label>
|
||||
<label>Password <input type="password" name="password" required></label>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
</section>
|
||||
{{- end}}
|
||||
@@ -32,6 +32,7 @@ type AdminConsoleHandlers struct {
|
||||
games GameAdmin
|
||||
runtime RuntimeAdmin
|
||||
engineVersions EngineVersionAdmin
|
||||
operators OperatorAdmin
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@ type AdminConsoleDeps struct {
|
||||
Games GameAdmin
|
||||
Runtime RuntimeAdmin
|
||||
EngineVersions EngineVersionAdmin
|
||||
Operators OperatorAdmin
|
||||
Logger *zap.Logger
|
||||
}
|
||||
|
||||
@@ -89,6 +91,7 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
|
||||
games: deps.Games,
|
||||
runtime: deps.Runtime,
|
||||
engineVersions: deps.EngineVersions,
|
||||
operators: deps.Operators,
|
||||
logger: logger.Named("http.admin.console"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/backend/internal/admin"
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// OperatorAdmin is the subset of the admin-account service the console uses.
|
||||
// *admin.Service satisfies it.
|
||||
type OperatorAdmin interface {
|
||||
List(ctx context.Context) ([]admin.Admin, error)
|
||||
Create(ctx context.Context, in admin.CreateInput) (admin.Admin, error)
|
||||
Disable(ctx context.Context, username string) (admin.Admin, error)
|
||||
Enable(ctx context.Context, username string) (admin.Admin, error)
|
||||
ResetPassword(ctx context.Context, username, password string) (admin.Admin, error)
|
||||
}
|
||||
|
||||
// OperatorsList renders GET /_gm/operators.
|
||||
func (h *AdminConsoleHandlers) OperatorsList() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.operators == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
admins, err := h.operators.List(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list operators", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "operators", "Operators", "Failed to load operators.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
h.render(c, http.StatusOK, "operators", "operators", "Operators", toOperatorsData(admins))
|
||||
}
|
||||
}
|
||||
|
||||
// OperatorCreate handles POST /_gm/operators.
|
||||
func (h *AdminConsoleHandlers) OperatorCreate() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.operators == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
_, err := h.operators.Create(c.Request.Context(), admin.CreateInput{
|
||||
Username: strings.TrimSpace(c.PostForm("username")),
|
||||
Password: c.PostForm("password"),
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, admin.ErrUsernameTaken):
|
||||
h.renderMessage(c, http.StatusConflict, "operators", "Username taken", "That username is already in use.", "bad", "/_gm/operators")
|
||||
case errors.Is(err, admin.ErrInvalidInput):
|
||||
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "Username and password are required.", "bad", "/_gm/operators")
|
||||
default:
|
||||
h.logger.Error("admin console: create operator", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "operators", "Create failed", "Failed to create the operator.", "bad", "/_gm/operators")
|
||||
}
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/_gm/operators")
|
||||
}
|
||||
}
|
||||
|
||||
// OperatorDisable handles POST /_gm/operators/:username/disable.
|
||||
func (h *AdminConsoleHandlers) OperatorDisable() gin.HandlerFunc {
|
||||
return h.operatorAction("disable", func(ctx context.Context, username string) error {
|
||||
_, err := h.operators.Disable(ctx, username)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// OperatorEnable handles POST /_gm/operators/:username/enable.
|
||||
func (h *AdminConsoleHandlers) OperatorEnable() gin.HandlerFunc {
|
||||
return h.operatorAction("enable", func(ctx context.Context, username string) error {
|
||||
_, err := h.operators.Enable(ctx, username)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// OperatorResetPassword handles POST /_gm/operators/:username/reset-password.
|
||||
func (h *AdminConsoleHandlers) OperatorResetPassword() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.operators == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
username := c.Param("username")
|
||||
password := c.PostForm("password")
|
||||
if strings.TrimSpace(password) == "" {
|
||||
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "A new password is required.", "bad", "/_gm/operators")
|
||||
return
|
||||
}
|
||||
if _, err := h.operators.ResetPassword(c.Request.Context(), username, password); err != nil {
|
||||
if errors.Is(err, admin.ErrNotFound) {
|
||||
h.renderMessage(c, http.StatusNotFound, "operators", "Operator not found", "No such operator.", "bad", "/_gm/operators")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrInvalidInput) {
|
||||
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "The password was rejected.", "bad", "/_gm/operators")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin console: reset operator password", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "operators", "Reset failed", "Failed to reset the password.", "bad", "/_gm/operators")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/_gm/operators")
|
||||
}
|
||||
}
|
||||
|
||||
// operatorAction is the shared shape for operator POST actions that take only
|
||||
// the username and redirect back to the list.
|
||||
func (h *AdminConsoleHandlers) operatorAction(label string, run func(context.Context, string) error) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.operators == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
if err := run(c.Request.Context(), c.Param("username")); err != nil {
|
||||
if errors.Is(err, admin.ErrNotFound) {
|
||||
h.renderMessage(c, http.StatusNotFound, "operators", "Operator not found", "No such operator.", "bad", "/_gm/operators")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin console: operator "+label, zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "operators", "Action failed", "The "+label+" action failed.", "bad", "/_gm/operators")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/_gm/operators")
|
||||
}
|
||||
}
|
||||
|
||||
// toOperatorsData maps admin accounts into the operators view model.
|
||||
func toOperatorsData(admins []admin.Admin) adminconsole.OperatorsData {
|
||||
data := adminconsole.OperatorsData{Items: make([]adminconsole.OperatorRow, 0, len(admins))}
|
||||
for _, a := range admins {
|
||||
data.Items = append(data.Items, adminconsole.OperatorRow{
|
||||
Username: a.Username,
|
||||
CreatedAt: fmtConsoleTime(a.CreatedAt),
|
||||
LastUsedAt: fmtConsoleTimePtr(a.LastUsedAt),
|
||||
Disabled: a.DisabledAt != nil,
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"galaxy/backend/internal/admin"
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type fakeOperatorAdmin struct {
|
||||
list []admin.Admin
|
||||
createErr error
|
||||
|
||||
created admin.CreateInput
|
||||
createCalls int
|
||||
disableCalls int
|
||||
enableCalls int
|
||||
resetCalls int
|
||||
lastResetUser string
|
||||
lastResetPass string
|
||||
}
|
||||
|
||||
func (f *fakeOperatorAdmin) List(context.Context) ([]admin.Admin, error) { return f.list, nil }
|
||||
func (f *fakeOperatorAdmin) Create(_ context.Context, in admin.CreateInput) (admin.Admin, error) {
|
||||
f.createCalls++
|
||||
f.created = in
|
||||
if f.createErr != nil {
|
||||
return admin.Admin{}, f.createErr
|
||||
}
|
||||
return admin.Admin{Username: in.Username}, nil
|
||||
}
|
||||
func (f *fakeOperatorAdmin) Disable(_ context.Context, username string) (admin.Admin, error) {
|
||||
f.disableCalls++
|
||||
return admin.Admin{Username: username}, nil
|
||||
}
|
||||
func (f *fakeOperatorAdmin) Enable(_ context.Context, username string) (admin.Admin, error) {
|
||||
f.enableCalls++
|
||||
return admin.Admin{Username: username}, nil
|
||||
}
|
||||
func (f *fakeOperatorAdmin) ResetPassword(_ context.Context, username, password string) (admin.Admin, error) {
|
||||
f.resetCalls++
|
||||
f.lastResetUser = username
|
||||
f.lastResetPass = password
|
||||
return admin.Admin{Username: username}, nil
|
||||
}
|
||||
|
||||
func operatorsRouter(t *testing.T, operators OperatorAdmin) (http.Handler, *adminconsole.CSRF) {
|
||||
t.Helper()
|
||||
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
||||
handler, err := NewRouter(RouterDependencies{
|
||||
Logger: zap.NewNop(),
|
||||
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
||||
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Operators: operators}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
return handler, csrf
|
||||
}
|
||||
|
||||
func TestConsoleOperatorsList(t *testing.T) {
|
||||
fake := &fakeOperatorAdmin{list: []admin.Admin{{Username: "root"}}}
|
||||
router, _ := operatorsRouter(t, fake)
|
||||
|
||||
rec := consoleGet(t, router, "/_gm/operators")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, want := range []string{"root", "Create operator", "Reset"} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Errorf("operators page missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleOperatorCreate(t *testing.T) {
|
||||
fake := &fakeOperatorAdmin{}
|
||||
router, csrf := operatorsRouter(t, fake)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/operators", "_csrf="+csrf.Token("ops")+"&username=mod&password=s3cret")
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if fake.createCalls != 1 || fake.created.Username != "mod" || fake.created.Password != "s3cret" {
|
||||
t.Errorf("create recorded %d username=%q", fake.createCalls, fake.created.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleOperatorCreateConflict(t *testing.T) {
|
||||
fake := &fakeOperatorAdmin{createErr: admin.ErrUsernameTaken}
|
||||
router, csrf := operatorsRouter(t, fake)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/operators", "_csrf="+csrf.Token("ops")+"&username=root&password=x")
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleOperatorDisableEnable(t *testing.T) {
|
||||
fake := &fakeOperatorAdmin{}
|
||||
router, csrf := operatorsRouter(t, fake)
|
||||
|
||||
if rec := consolePost(t, router, "/_gm/operators/root/disable", "_csrf="+csrf.Token("ops")); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("disable status = %d, want 303", rec.Code)
|
||||
}
|
||||
if rec := consolePost(t, router, "/_gm/operators/root/enable", "_csrf="+csrf.Token("ops")); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("enable status = %d, want 303", rec.Code)
|
||||
}
|
||||
if fake.disableCalls != 1 || fake.enableCalls != 1 {
|
||||
t.Errorf("disable=%d enable=%d, want 1/1", fake.disableCalls, fake.enableCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleOperatorResetPassword(t *testing.T) {
|
||||
fake := &fakeOperatorAdmin{}
|
||||
router, csrf := operatorsRouter(t, fake)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/operators/root/reset-password", "_csrf="+csrf.Token("ops")+"&password=newpass")
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rec.Code)
|
||||
}
|
||||
if fake.resetCalls != 1 || fake.lastResetUser != "root" || fake.lastResetPass != "newpass" {
|
||||
t.Errorf("reset recorded %d user=%q", fake.resetCalls, fake.lastResetUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleOperatorResetPasswordMissing(t *testing.T) {
|
||||
fake := &fakeOperatorAdmin{}
|
||||
router, csrf := operatorsRouter(t, fake)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/operators/root/reset-password", "_csrf="+csrf.Token("ops"))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if fake.resetCalls != 0 {
|
||||
t.Error("reset must not run without a password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleOperatorRejectsBadCSRF(t *testing.T) {
|
||||
fake := &fakeOperatorAdmin{}
|
||||
router, _ := operatorsRouter(t, fake)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/operators/root/disable", "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
if fake.disableCalls != 0 {
|
||||
t.Error("disable must not run without a CSRF token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleOperatorsUnavailable(t *testing.T) {
|
||||
router, _ := operatorsRouter(t, nil)
|
||||
|
||||
rec := consoleGet(t, router, "/_gm/operators")
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -408,6 +408,12 @@ func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) {
|
||||
group.GET("/engine-versions", deps.AdminConsole.EngineVersionsList())
|
||||
group.POST("/engine-versions", deps.AdminConsole.EngineVersionRegister())
|
||||
group.POST("/engine-versions/:version/disable", deps.AdminConsole.EngineVersionDisable())
|
||||
|
||||
group.GET("/operators", deps.AdminConsole.OperatorsList())
|
||||
group.POST("/operators", deps.AdminConsole.OperatorCreate())
|
||||
group.POST("/operators/:username/disable", deps.AdminConsole.OperatorDisable())
|
||||
group.POST("/operators/:username/enable", deps.AdminConsole.OperatorEnable())
|
||||
group.POST("/operators/:username/reset-password", deps.AdminConsole.OperatorResetPassword())
|
||||
}
|
||||
|
||||
// allowedMethodsForPath returns the comma-separated list of methods
|
||||
|
||||
Reference in New Issue
Block a user