feat(admin): manual account blocking (suspensions)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s

Operator-driven hard block, the counterpart to the soft high-rate flag: permanent or until a date, with an optional reason chosen from an editable en+ru picklist (snapshotted onto the block). A block forfeits the player's active games (opponent wins, as a resignation) and cancels their open matchmaking games. A backend gate refuses a blocked account on every /api/v1/user/* route except the block-status probe with 403 account_blocked, which threads through the gateway as the Execute result_code; the UI surfaces it as a terminal blocked screen and stops all push/poll. Temporary blocks self-expire; the operator can unblock at any time (lost games stay lost). Sessions are not revoked, so the blocked client can still reach the exempt block-status endpoint.

Backend: migration 00003 (account_suspensions + suspension_reasons) + jet regen; account suspension store; game.ForfeitAllForAccount; requireNotSuspended gate + block-status endpoint; admin console block/unblock + Reasons CRUD. Wire: fbs BlockStatus + account.block_status gateway op. UI: blocked screen, app state, transport/codec, i18n. Docs: ARCHITECTURE, FUNCTIONAL(+ru), PRERELEASE (AB).
This commit is contained in:
Ilia Denisov
2026-06-14 21:55:59 +02:00
parent 9d85090075
commit d1ba666495
48 changed files with 2206 additions and 2 deletions
@@ -51,6 +51,12 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.GET("/users/:id", s.consoleUserDetail)
gm.POST("/users/:id/message", s.consoleUserMessage)
gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag)
gm.POST("/users/:id/block", s.consoleBlockUser)
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
gm.GET("/reasons", s.consoleReasons)
gm.POST("/reasons", s.consoleCreateReason)
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
gm.POST("/reasons/:id/delete", s.consoleDeleteReason)
gm.GET("/throttled", s.consoleThrottled)
gm.GET("/games", s.consoleGames)
gm.GET("/games/:id", s.consoleGameDetail)
@@ -291,6 +297,20 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
}
view.MoveChart = adminconsole.MoveDurationChart(cps)
}
if susp, blocked, err := s.accounts.CurrentSuspension(ctx, id); err == nil && blocked {
view.Suspension = adminconsole.SuspensionView{
Blocked: true, Permanent: susp.Permanent(), BlockedAt: fmtTime(susp.BlockedAt),
ReasonEn: susp.ReasonEn, ReasonRu: susp.ReasonRu,
}
if susp.BlockedUntil != nil {
view.Suspension.Until = fmtTime(*susp.BlockedUntil)
}
}
if reasons, err := s.accounts.ListReasons(ctx); err == nil {
for _, r := range reasons {
view.Reasons = append(view.Reasons, adminconsole.ReasonOption{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu})
}
}
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
@@ -754,6 +774,157 @@ func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String())
}
// consoleBlockUser manually blocks an account: it records the suspension (permanent or until a
// parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active
// games, removing them from matchmaking. The block takes effect on the player's next request.
func (s *Server) consoleBlockUser(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
until, ok := parseSuspendUntil(trimForm(c, "duration"), trimForm(c, "until"))
if !ok {
s.renderConsoleMessage(c, "Invalid duration", "pick a preset or a future custom date/time (UTC)", back)
return
}
var reasonEn, reasonRu string
var reasonID *uuid.UUID
if rid := trimForm(c, "reason"); rid != "" {
parsed, err := uuid.Parse(rid)
if err != nil {
s.renderConsoleMessage(c, "Invalid reason", "the selected reason is not valid", back)
return
}
reason, err := s.accounts.GetReason(ctx, parsed)
if err != nil {
s.consoleError(c, err)
return
}
reasonEn, reasonRu, reasonID = reason.TextEn, reason.TextRu, &reason.ID
}
if _, err := s.accounts.Suspend(ctx, id, until, reasonEn, reasonRu, reasonID); err != nil {
s.consoleError(c, err)
return
}
forfeited, err := s.games.ForfeitAllForAccount(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Blocked", fmt.Sprintf("account blocked; %d game(s) forfeited", forfeited), back)
}
// consoleUnblockUser lifts an account's block (temporary or permanent). Games already lost at
// block time are not restored.
func (s *Server) consoleUnblockUser(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
if err := s.accounts.LiftSuspension(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Unblocked", "the block was lifted; lost games are not restored", "/_gm/users/"+id.String())
}
// parseSuspendUntil maps the block form's duration choice to an expiry instant, returning nil for
// a permanent block. A custom choice parses the datetime-local value as UTC and must be in the
// future. It reports false for an unrecognised choice or an invalid/past custom date.
func parseSuspendUntil(choice, custom string) (*time.Time, bool) {
now := time.Now().UTC()
switch choice {
case "permanent":
return nil, true
case "1d":
t := now.AddDate(0, 0, 1)
return &t, true
case "3d":
t := now.AddDate(0, 0, 3)
return &t, true
case "1w":
t := now.AddDate(0, 0, 7)
return &t, true
case "1m":
t := now.AddDate(0, 1, 0)
return &t, true
case "custom":
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05"} {
if t, err := time.Parse(layout, custom); err == nil {
if !t.After(now) {
return nil, false
}
return &t, true
}
}
return nil, false
default:
return nil, false
}
}
// consoleReasons renders the operator-editable suspension-reason picklist.
func (s *Server) consoleReasons(c *gin.Context) {
reasons, err := s.accounts.ListReasons(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
var view adminconsole.ReasonsView
for _, r := range reasons {
view.Items = append(view.Items, adminconsole.ReasonRow{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu, CreatedAt: fmtTime(r.CreatedAt)})
}
s.renderConsole(c, "reasons", "reasons", "Reasons", view)
}
// consoleCreateReason adds a suspension-reason picklist entry; both languages are required.
func (s *Server) consoleCreateReason(c *gin.Context) {
en, ru := trimForm(c, "text_en"), trimForm(c, "text_ru")
if en == "" || ru == "" {
s.renderConsoleMessage(c, "Nothing added", "both English and Russian text are required", "/_gm/reasons")
return
}
if _, err := s.accounts.CreateReason(c.Request.Context(), en, ru); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Added", "reason added", "/_gm/reasons")
}
// consoleUpdateReason rewrites a reason's English and Russian text. Existing blocks keep their
// snapshot, so the change only affects future blocks.
func (s *Server) consoleUpdateReason(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/reasons")
if !ok {
return
}
en, ru := trimForm(c, "text_en"), trimForm(c, "text_ru")
if en == "" || ru == "" {
s.renderConsoleMessage(c, "Not changed", "both English and Russian text are required", "/_gm/reasons")
return
}
if _, err := s.accounts.UpdateReason(c.Request.Context(), id, en, ru); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Updated", "reason updated", "/_gm/reasons")
}
// consoleDeleteReason removes a reason from the picklist. Past blocks keep their text snapshot.
func (s *Server) consoleDeleteReason(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/reasons")
if !ok {
return
}
if err := s.accounts.DeleteReason(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Deleted", "reason deleted", "/_gm/reasons")
}
// variantVersions builds the per-variant resident-version summary from the registry.
func (s *Server) variantVersions() []adminconsole.VariantVersions {
out := make([]adminconsole.VariantVersions, 0, len(engine.Variants()))