87a272166b
Tests · Go / test (push) Successful in 1m59s
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.
150 lines
5.9 KiB
Go
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
|
|
}
|