From 87a272166bcfef119a7fdc4642a6bb88786e2e74 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 31 May 2026 20:31:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin-console):=20Stage=205=20=E2=80=94=20?= =?UTF-8?q?operators=20(admin=20accounts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the operator-management page over *admin.Service (no new business logic). - GET/POST /_gm/operators list + create operator - POST /_gm/operators/{user}/disable|enable toggle access - POST /_gm/operators/{user}/reset-password set a new password Console depends on an OperatorAdmin interface (satisfied by *admin.Service) so the page renders in tests without a database. Create POST is mounted on the collection path; per-row disable/enable/reset are guarded by the CSRF middleware and redirect back. Passwords are never logged. Tests: list render, create (+ username/password assertions), username-taken conflict, disable/enable, reset (+ password assertion), missing-password 400, bad-CSRF 403, and unavailable 503. Docs: backend/docs/admin-console.md page inventory extended. --- backend/cmd/backend/main.go | 1 + backend/docs/admin-console.md | 4 + backend/internal/adminconsole/operators.go | 14 ++ .../templates/pages/operators.gohtml | 38 ++++ .../internal/server/handlers_admin_console.go | 3 + .../handlers_admin_console_operators.go | 149 ++++++++++++++++ .../handlers_admin_console_operators_test.go | 166 ++++++++++++++++++ backend/internal/server/router.go | 6 + 8 files changed, 381 insertions(+) create mode 100644 backend/internal/adminconsole/operators.go create mode 100644 backend/internal/adminconsole/templates/pages/operators.gohtml create mode 100644 backend/internal/server/handlers_admin_console_operators.go create mode 100644 backend/internal/server/handlers_admin_console_operators_test.go diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 3c2ab5a..79968e1 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -381,6 +381,7 @@ func run(ctx context.Context) (err error) { Games: lobbySvc, Runtime: runtimeSvc, EngineVersions: engineVersionSvc, + Operators: adminSvc, Logger: logger, }) diff --git a/backend/docs/admin-console.md b/backend/docs/admin-console.md index d181a81..9694a11 100644 --- a/backend/docs/admin-console.md +++ b/backend/docs/admin-console.md @@ -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 diff --git a/backend/internal/adminconsole/operators.go b/backend/internal/adminconsole/operators.go new file mode 100644 index 0000000..e3545fe --- /dev/null +++ b/backend/internal/adminconsole/operators.go @@ -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 +} diff --git a/backend/internal/adminconsole/templates/pages/operators.gohtml b/backend/internal/adminconsole/templates/pages/operators.gohtml new file mode 100644 index 0000000..f944ace --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/operators.gohtml @@ -0,0 +1,38 @@ +{{define "content" -}} +{{$csrf := .CSRFToken}} +

Operators

+{{with .Data}} + + + +{{range .Items}} + + + + + + + +{{else}}{{end}} + +
UsernameStatusCreatedLast usedActions
{{.Username}}{{if .Disabled}}disabled{{else}}active{{end}}{{.CreatedAt}}{{if .LastUsedAt}}{{.LastUsedAt}}{{else}}—{{end}} +
+{{if .Disabled}} +
+{{else}} +
+{{end}} +
+
+
no operators
+{{end}} +
+

Create operator

+
+ + + + +
+
+{{- end}} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index cf9b834..d58e4ee 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -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"), } } diff --git a/backend/internal/server/handlers_admin_console_operators.go b/backend/internal/server/handlers_admin_console_operators.go new file mode 100644 index 0000000..e02c36b --- /dev/null +++ b/backend/internal/server/handlers_admin_console_operators.go @@ -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 +} diff --git a/backend/internal/server/handlers_admin_console_operators_test.go b/backend/internal/server/handlers_admin_console_operators_test.go new file mode 100644 index 0000000..c2f325b --- /dev/null +++ b/backend/internal/server/handlers_admin_console_operators_test.go @@ -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) + } +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index bab38d2..4e5c3e5 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -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