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}}
+
+| Username | Status | Created | Last used | Actions |
+
+{{range .Items}}
+
+| {{.Username}} |
+{{if .Disabled}}disabled{{else}}active{{end}} |
+{{.CreatedAt}} |
+{{if .LastUsedAt}}{{.LastUsedAt}}{{else}}—{{end}} |
+
+
+{{if .Disabled}}
+
+{{else}}
+
+{{end}}
+
+
+ |
+
+{{else}}| no operators |
{{end}}
+
+
+{{end}}
+
+{{- 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