221 lines
7.1 KiB
Go
221 lines
7.1 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"galaxy/backend/internal/admin"
|
|
"galaxy/backend/internal/server/handlers"
|
|
"galaxy/backend/internal/server/httperr"
|
|
"galaxy/backend/internal/telemetry"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// AdminAdminAccountsHandlers groups the admin-account CRUD handlers
|
|
// under `/api/v1/admin/admin-accounts/*`. The current implementation ships real
|
|
// implementations backed by `*admin.Service`; tests that supply a nil
|
|
// service fall back to the Stage-3 placeholder body so the contract
|
|
// test continues to validate the OpenAPI envelope without booting a
|
|
// database.
|
|
type AdminAdminAccountsHandlers struct {
|
|
svc *admin.Service
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewAdminAdminAccountsHandlers constructs the handler set. svc may be
|
|
// nil — in that case every handler returns 501 not_implemented,
|
|
// matching the pre-Stage-5.3 placeholder. logger may also be nil;
|
|
// zap.NewNop is used in that case.
|
|
func NewAdminAdminAccountsHandlers(svc *admin.Service, logger *zap.Logger) *AdminAdminAccountsHandlers {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &AdminAdminAccountsHandlers{svc: svc, logger: logger.Named("http.admin.admin-accounts")}
|
|
}
|
|
|
|
// List handles GET /api/v1/admin/admin-accounts.
|
|
func (h *AdminAdminAccountsHandlers) List() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("adminAdminAccountsList")
|
|
}
|
|
return func(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
admins, err := h.svc.List(ctx)
|
|
if err != nil {
|
|
respondAdminAccountError(c, h.logger, "admin admin-accounts list", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, adminAccountListToWire(admins))
|
|
}
|
|
}
|
|
|
|
// Create handles POST /api/v1/admin/admin-accounts.
|
|
func (h *AdminAdminAccountsHandlers) Create() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("adminAdminAccountsCreate")
|
|
}
|
|
return func(c *gin.Context) {
|
|
var req adminAccountCreateRequestWire
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
created, err := h.svc.Create(ctx, admin.CreateInput{
|
|
Username: req.Username,
|
|
Password: req.Password,
|
|
})
|
|
if err != nil {
|
|
respondAdminAccountError(c, h.logger, "admin admin-accounts create", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, adminAccountToWire(created))
|
|
}
|
|
}
|
|
|
|
// Get handles GET /api/v1/admin/admin-accounts/{username}.
|
|
func (h *AdminAdminAccountsHandlers) Get() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("adminAdminAccountsGet")
|
|
}
|
|
return func(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
got, err := h.svc.Get(ctx, c.Param("username"))
|
|
if err != nil {
|
|
respondAdminAccountError(c, h.logger, "admin admin-accounts get", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, adminAccountToWire(got))
|
|
}
|
|
}
|
|
|
|
// Disable handles POST /api/v1/admin/admin-accounts/{username}/disable.
|
|
func (h *AdminAdminAccountsHandlers) Disable() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("adminAdminAccountsDisable")
|
|
}
|
|
return func(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
updated, err := h.svc.Disable(ctx, c.Param("username"))
|
|
if err != nil {
|
|
respondAdminAccountError(c, h.logger, "admin admin-accounts disable", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, adminAccountToWire(updated))
|
|
}
|
|
}
|
|
|
|
// Enable handles POST /api/v1/admin/admin-accounts/{username}/enable.
|
|
func (h *AdminAdminAccountsHandlers) Enable() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("adminAdminAccountsEnable")
|
|
}
|
|
return func(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
updated, err := h.svc.Enable(ctx, c.Param("username"))
|
|
if err != nil {
|
|
respondAdminAccountError(c, h.logger, "admin admin-accounts enable", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, adminAccountToWire(updated))
|
|
}
|
|
}
|
|
|
|
// ResetPassword handles POST /api/v1/admin/admin-accounts/{username}/reset-password.
|
|
func (h *AdminAdminAccountsHandlers) ResetPassword() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("adminAdminAccountsResetPassword")
|
|
}
|
|
return func(c *gin.Context) {
|
|
var req adminAccountResetPasswordRequestWire
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
updated, err := h.svc.ResetPassword(ctx, c.Param("username"), req.Password)
|
|
if err != nil {
|
|
respondAdminAccountError(c, h.logger, "admin admin-accounts reset-password", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, adminAccountToWire(updated))
|
|
}
|
|
}
|
|
|
|
// respondAdminAccountError maps admin-package sentinels to the standard
|
|
// JSON envelope. Unknown errors fall through to 500 with a structured
|
|
// log so operators can correlate.
|
|
func respondAdminAccountError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
|
|
switch {
|
|
case errors.Is(err, admin.ErrNotFound):
|
|
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "admin account not found")
|
|
case errors.Is(err, admin.ErrUsernameTaken):
|
|
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, "username already in use")
|
|
case errors.Is(err, admin.ErrInvalidInput):
|
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
|
|
default:
|
|
logger.Error(op+" failed",
|
|
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
|
)
|
|
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal error")
|
|
}
|
|
}
|
|
|
|
// adminAccountToWire renders an admin.Admin into the OpenAPI
|
|
// `AdminAccount` schema declared at openapi.yaml:2596.
|
|
func adminAccountToWire(a admin.Admin) adminAccountWire {
|
|
out := adminAccountWire{
|
|
Username: a.Username,
|
|
CreatedAt: a.CreatedAt.UTC().Format(timestampLayout),
|
|
}
|
|
if a.LastUsedAt != nil {
|
|
t := a.LastUsedAt.UTC().Format(timestampLayout)
|
|
out.LastUsedAt = &t
|
|
}
|
|
if a.DisabledAt != nil {
|
|
t := a.DisabledAt.UTC().Format(timestampLayout)
|
|
out.DisabledAt = &t
|
|
}
|
|
return out
|
|
}
|
|
|
|
// adminAccountListToWire renders the admin slice into the OpenAPI
|
|
// `AdminAccountList` schema.
|
|
func adminAccountListToWire(admins []admin.Admin) adminAccountListWire {
|
|
out := adminAccountListWire{
|
|
Items: make([]adminAccountWire, 0, len(admins)),
|
|
}
|
|
for _, a := range admins {
|
|
out.Items = append(out.Items, adminAccountToWire(a))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// adminAccountWire mirrors `AdminAccount`.
|
|
type adminAccountWire struct {
|
|
Username string `json:"username"`
|
|
CreatedAt string `json:"created_at"`
|
|
LastUsedAt *string `json:"last_used_at,omitempty"`
|
|
DisabledAt *string `json:"disabled_at,omitempty"`
|
|
}
|
|
|
|
// adminAccountListWire mirrors `AdminAccountList`.
|
|
type adminAccountListWire struct {
|
|
Items []adminAccountWire `json:"items"`
|
|
}
|
|
|
|
// adminAccountCreateRequestWire mirrors `AdminAccountCreateRequest`.
|
|
type adminAccountCreateRequestWire struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
// adminAccountResetPasswordRequestWire mirrors
|
|
// `AdminAccountResetPasswordRequest`.
|
|
type adminAccountResetPasswordRequestWire struct {
|
|
Password string `json:"password"`
|
|
}
|