Files
galaxy-game/backend/internal/server/handlers_admin_console_operators.go
T
Ilia Denisov 87a272166b
Tests · Go / test (push) Successful in 1m59s
feat(admin-console): Stage 5 — operators (admin accounts)
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.
2026-05-31 20:31:16 +02:00

150 lines
5.9 KiB
Go

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
}