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) }