cf34710b4f
Tests · Go / test (push) Successful in 1m56s
Add the operator console's user-administration pages over the existing
*user.Service (no new business logic).
- GET /_gm/users paginated account list
- GET /_gm/users/{id} account detail: profile, entitlement, sanctions
- POST /_gm/users/{id}/block apply permanent_block (reason required)
- POST /_gm/users/{id}/entitlement set the entitlement tier
- POST /_gm/users/{id}/soft-delete soft-delete the account (cascades)
The console depends on a UserAdmin interface (satisfied by *user.Service) so the
pages render in tests without a database. All writes flow through the CSRF
guard, carry the operator as the audit actor, and answer with a 303 redirect;
a generic message page handles not-found, validation, and failure notices.
Unblock is intentionally absent — the admin API exposes no remove-sanction
endpoint.
Tests: list/detail render, not-found, block (with actor/scope/reason
assertions), missing-reason 400, bad-CSRF 403, entitlement, soft-delete
redirect, and the service-unavailable path.
Docs: backend/docs/admin-console.md gains the page inventory.
253 lines
8.9 KiB
Go
253 lines
8.9 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"galaxy/backend/internal/adminconsole"
|
|
"galaxy/backend/internal/server/middleware/basicauth"
|
|
"galaxy/backend/internal/user"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// UserAdmin is the subset of the user service the operator console depends on.
|
|
// *user.Service satisfies it; tests supply a fake so the console pages render
|
|
// without a database.
|
|
type UserAdmin interface {
|
|
ListAccounts(ctx context.Context, page, pageSize int) (user.AccountPage, error)
|
|
GetAccount(ctx context.Context, userID uuid.UUID) (user.Account, error)
|
|
ApplySanction(ctx context.Context, input user.ApplySanctionInput) (user.Account, error)
|
|
ApplyEntitlement(ctx context.Context, input user.ApplyEntitlementInput) (user.Account, error)
|
|
SoftDelete(ctx context.Context, userID uuid.UUID, actor user.ActorRef) error
|
|
}
|
|
|
|
// consoleTiers lists the selectable entitlement tiers in display order.
|
|
var consoleTiers = []string{user.TierFree, user.TierMonthly, user.TierYearly, user.TierPermanent}
|
|
|
|
// UsersList renders GET /_gm/users — the paginated account list.
|
|
func (h *AdminConsoleHandlers) UsersList() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if h.users == nil {
|
|
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
|
return
|
|
}
|
|
page := parsePositiveQueryInt(c.Query("page"), 1)
|
|
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
|
|
|
result, err := h.users.ListAccounts(c.Request.Context(), page, pageSize)
|
|
if err != nil {
|
|
h.logger.Error("admin console: list users", zap.Error(err))
|
|
h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load users.", "bad", "/_gm/")
|
|
return
|
|
}
|
|
h.render(c, http.StatusOK, "users", "users", "Users", toUsersListData(result))
|
|
}
|
|
}
|
|
|
|
// UserDetail renders GET /_gm/users/:user_id.
|
|
func (h *AdminConsoleHandlers) UserDetail() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if h.users == nil {
|
|
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
|
return
|
|
}
|
|
userID, ok := parseUserIDParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
account, err := h.users.GetAccount(c.Request.Context(), userID)
|
|
if err != nil {
|
|
if errors.Is(err, user.ErrAccountNotFound) {
|
|
h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user, or the account has been soft-deleted.", "bad", "/_gm/users")
|
|
return
|
|
}
|
|
h.logger.Error("admin console: get user", zap.Error(err))
|
|
h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load the user.", "bad", "/_gm/users")
|
|
return
|
|
}
|
|
h.render(c, http.StatusOK, "user_detail", "users", account.Email, toUserDetailData(account))
|
|
}
|
|
}
|
|
|
|
// UserBlock handles POST /_gm/users/:user_id/block — applies a permanent block.
|
|
func (h *AdminConsoleHandlers) UserBlock() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if h.users == nil {
|
|
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
|
return
|
|
}
|
|
userID, ok := parseUserIDParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
back := "/_gm/users/" + userID.String()
|
|
reason := strings.TrimSpace(c.PostForm("reason_code"))
|
|
if reason == "" {
|
|
h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "A reason is required to block a user.", "bad", back)
|
|
return
|
|
}
|
|
_, err := h.users.ApplySanction(c.Request.Context(), user.ApplySanctionInput{
|
|
UserID: userID,
|
|
SanctionCode: user.SanctionCodePermanentBlock,
|
|
Scope: "account",
|
|
ReasonCode: reason,
|
|
Actor: actorFromContext(c),
|
|
})
|
|
if err != nil {
|
|
h.logger.Error("admin console: block user", zap.Error(err))
|
|
h.renderMessage(c, http.StatusInternalServerError, "users", "Block failed", "Failed to block the user.", "bad", back)
|
|
return
|
|
}
|
|
c.Redirect(http.StatusSeeOther, back)
|
|
}
|
|
}
|
|
|
|
// UserEntitlement handles POST /_gm/users/:user_id/entitlement.
|
|
func (h *AdminConsoleHandlers) UserEntitlement() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if h.users == nil {
|
|
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
|
return
|
|
}
|
|
userID, ok := parseUserIDParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
back := "/_gm/users/" + userID.String()
|
|
tier := strings.TrimSpace(c.PostForm("tier"))
|
|
source := strings.TrimSpace(c.PostForm("source"))
|
|
if source == "" {
|
|
source = "admin"
|
|
}
|
|
_, err := h.users.ApplyEntitlement(c.Request.Context(), user.ApplyEntitlementInput{
|
|
UserID: userID,
|
|
Tier: tier,
|
|
Source: source,
|
|
Actor: actorFromContext(c),
|
|
ReasonCode: strings.TrimSpace(c.PostForm("reason_code")),
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, user.ErrInvalidInput) {
|
|
h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "The entitlement request was rejected: check the tier.", "bad", back)
|
|
return
|
|
}
|
|
h.logger.Error("admin console: apply entitlement", zap.Error(err))
|
|
h.renderMessage(c, http.StatusInternalServerError, "users", "Entitlement failed", "Failed to update the entitlement.", "bad", back)
|
|
return
|
|
}
|
|
c.Redirect(http.StatusSeeOther, back)
|
|
}
|
|
}
|
|
|
|
// UserSoftDelete handles POST /_gm/users/:user_id/soft-delete.
|
|
func (h *AdminConsoleHandlers) UserSoftDelete() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if h.users == nil {
|
|
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
|
return
|
|
}
|
|
userID, ok := parseUserIDParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.users.SoftDelete(c.Request.Context(), userID, actorFromContext(c)); err != nil {
|
|
if errors.Is(err, user.ErrAccountNotFound) {
|
|
h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user.", "bad", "/_gm/users")
|
|
return
|
|
}
|
|
// A cascade error does not undo the soft delete; log and proceed.
|
|
h.logger.Warn("admin console: soft-delete cascade returned error", zap.Error(err))
|
|
}
|
|
c.Redirect(http.StatusSeeOther, "/_gm/users")
|
|
}
|
|
}
|
|
|
|
// actorFromContext builds the admin ActorRef for audit trails from the
|
|
// authenticated operator username stored by the Basic Auth middleware.
|
|
func actorFromContext(c *gin.Context) user.ActorRef {
|
|
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
|
return user.ActorRef{Type: "admin", ID: username}
|
|
}
|
|
|
|
// toUsersListData maps an account page into the users list view model.
|
|
func toUsersListData(page user.AccountPage) adminconsole.UsersListData {
|
|
data := adminconsole.UsersListData{
|
|
Items: make([]adminconsole.UserRow, 0, len(page.Items)),
|
|
Page: page.Page,
|
|
PageSize: page.PageSize,
|
|
Total: page.Total,
|
|
PrevPage: page.Page - 1,
|
|
NextPage: page.Page + 1,
|
|
HasPrev: page.Page > 1,
|
|
HasNext: page.Page*page.PageSize < page.Total,
|
|
}
|
|
for _, account := range page.Items {
|
|
data.Items = append(data.Items, adminconsole.UserRow{
|
|
UserID: account.UserID.String(),
|
|
Email: account.Email,
|
|
UserName: account.UserName,
|
|
DisplayName: account.DisplayName,
|
|
Tier: account.Entitlement.Tier,
|
|
Blocked: account.PermanentBlock,
|
|
Deleted: account.DeletedAt != nil,
|
|
CreatedAt: fmtConsoleTime(account.CreatedAt),
|
|
})
|
|
}
|
|
return data
|
|
}
|
|
|
|
// toUserDetailData maps an account aggregate into the detail view model.
|
|
func toUserDetailData(account user.Account) adminconsole.UserDetailData {
|
|
data := adminconsole.UserDetailData{
|
|
UserID: account.UserID.String(),
|
|
Email: account.Email,
|
|
UserName: account.UserName,
|
|
DisplayName: account.DisplayName,
|
|
PreferredLanguage: account.PreferredLanguage,
|
|
TimeZone: account.TimeZone,
|
|
DeclaredCountry: account.DeclaredCountry,
|
|
Blocked: account.PermanentBlock,
|
|
Deleted: account.DeletedAt != nil,
|
|
CreatedAt: fmtConsoleTime(account.CreatedAt),
|
|
UpdatedAt: fmtConsoleTime(account.UpdatedAt),
|
|
Tier: account.Entitlement.Tier,
|
|
IsPaid: account.Entitlement.IsPaid,
|
|
EntitlementSource: account.Entitlement.Source,
|
|
EntitlementReason: account.Entitlement.ReasonCode,
|
|
EntitlementEnds: fmtConsoleTimePtr(account.Entitlement.EndsAt),
|
|
Tiers: consoleTiers,
|
|
}
|
|
for _, sanction := range account.ActiveSanctions {
|
|
data.Sanctions = append(data.Sanctions, adminconsole.SanctionView{
|
|
SanctionCode: sanction.SanctionCode,
|
|
Scope: sanction.Scope,
|
|
ReasonCode: sanction.ReasonCode,
|
|
AppliedAt: fmtConsoleTime(sanction.AppliedAt),
|
|
ExpiresAt: fmtConsoleTimePtr(sanction.ExpiresAt),
|
|
})
|
|
}
|
|
return data
|
|
}
|
|
|
|
// fmtConsoleTime renders a timestamp for display in the console.
|
|
func fmtConsoleTime(t time.Time) string {
|
|
if t.IsZero() {
|
|
return ""
|
|
}
|
|
return t.UTC().Format("2006-01-02 15:04 UTC")
|
|
}
|
|
|
|
// fmtConsoleTimePtr renders an optional timestamp, returning "" when nil.
|
|
func fmtConsoleTimePtr(t *time.Time) string {
|
|
if t == nil {
|
|
return ""
|
|
}
|
|
return fmtConsoleTime(*t)
|
|
}
|