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
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:
@@ -18,6 +18,7 @@
|
||||
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
||||
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
||||
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
|
||||
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
|
||||
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
||||
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||
<a href="/_gm/grafana/">Grafana ↗</a>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "content" -}}
|
||||
<h1>Suspension reasons</h1>
|
||||
{{with .Data}}
|
||||
<p class="note">Reasons offered when blocking a user; each is shown to the blocked player in their own language. Editing or deleting a reason does not change a reason already shown to a currently-blocked player (blocks snapshot the text).</p>
|
||||
<section class="panel"><h2>Add reason</h2>
|
||||
<form class="form col" method="post" action="/_gm/reasons">
|
||||
<label>English <input type="text" name="text_en" required></label>
|
||||
<label>Russian <input type="text" name="text_ru" required></label>
|
||||
<div><button type="submit">Add</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Existing reasons</h2>
|
||||
{{range .Items}}
|
||||
<div class="row">
|
||||
<form class="form" method="post" action="/_gm/reasons/{{.ID}}/update">
|
||||
<input type="text" name="text_en" value="{{.TextEn}}" aria-label="English" required>
|
||||
<input type="text" name="text_ru" value="{{.TextRu}}" aria-label="Russian" required>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<form class="form" method="post" action="/_gm/reasons/{{.ID}}/delete">
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
{{else}}<p class="note">no reasons yet</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -34,6 +34,35 @@
|
||||
{{else}}<p class="note">no statistics</p>{{end}}
|
||||
</section>
|
||||
</div>
|
||||
<section class="panel"><h2>Account block</h2>
|
||||
{{$uid := .ID}}
|
||||
{{with .Suspension}}{{if .Blocked}}
|
||||
<ul class="kv">
|
||||
<li><b>Status</b> <span class="warn">{{if .Permanent}}blocked (permanent){{else}}blocked until {{.Until}} (UTC){{end}}</span></li>
|
||||
{{if .BlockedAt}}<li><b>Since</b> {{.BlockedAt}}</li>{{end}}
|
||||
{{if .ReasonEn}}<li><b>Reason</b> {{.ReasonEn}} / {{.ReasonRu}}</li>{{end}}
|
||||
</ul>
|
||||
<form class="form" method="post" action="/_gm/users/{{$uid}}/unblock">
|
||||
<button type="submit">Unblock</button>
|
||||
</form>
|
||||
{{else}}<p class="note">not blocked</p>{{end}}{{end}}
|
||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/block">
|
||||
<label>Duration <select name="duration">
|
||||
<option value="permanent">permanent</option>
|
||||
<option value="1d">1 day</option>
|
||||
<option value="3d">3 days</option>
|
||||
<option value="1w">1 week</option>
|
||||
<option value="1m">1 month</option>
|
||||
<option value="custom">custom date (UTC)</option>
|
||||
</select></label>
|
||||
<label>Custom until (UTC; used when duration is "custom") <input type="datetime-local" name="until"></label>
|
||||
<label>Reason <select name="reason">
|
||||
<option value="">— none —</option>
|
||||
{{range .Reasons}}<option value="{{.ID}}">{{.TextEn}}</option>{{end}}
|
||||
</select></label>
|
||||
<div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{if .MoveChart}}
|
||||
<section class="panel"><h2>Move timing</h2>
|
||||
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
|
||||
|
||||
@@ -135,6 +135,29 @@ type UserDetailView struct {
|
||||
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
||||
// time (min/mean/max), empty when the account has no timed move.
|
||||
MoveChart template.HTML
|
||||
// Suspension is the account's current manual-block state, shown with the block/unblock
|
||||
// form; Reasons is the operator-editable reason picklist offered in the block form.
|
||||
Suspension SuspensionView
|
||||
Reasons []ReasonOption
|
||||
}
|
||||
|
||||
// SuspensionView is an account's current manual-block state shown on the user card: whether it
|
||||
// is blocked, whether the block is permanent, the pre-formatted expiry (empty when permanent),
|
||||
// when it was applied, and the reason snapshot in both languages (empty when none was cited).
|
||||
type SuspensionView struct {
|
||||
Blocked bool
|
||||
Permanent bool
|
||||
Until string
|
||||
BlockedAt string
|
||||
ReasonEn string
|
||||
ReasonRu string
|
||||
}
|
||||
|
||||
// ReasonOption is one suspension-reason picklist entry offered in the block form's dropdown.
|
||||
type ReasonOption struct {
|
||||
ID string
|
||||
TextEn string
|
||||
TextRu string
|
||||
}
|
||||
|
||||
// StatsRow is an account's lifetime statistics.
|
||||
@@ -317,6 +340,19 @@ type FlaggedAccountRow struct {
|
||||
FlaggedAt string
|
||||
}
|
||||
|
||||
// ReasonsView is the suspension-reason picklist management page: every editable reason entry.
|
||||
type ReasonsView struct {
|
||||
Items []ReasonRow
|
||||
}
|
||||
|
||||
// ReasonRow is one editable suspension-reason entry, with its English and Russian text.
|
||||
type ReasonRow struct {
|
||||
ID string
|
||||
TextEn string
|
||||
TextRu string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// MessageView is the result page shown after a POST action.
|
||||
type MessageView struct {
|
||||
Heading string
|
||||
|
||||
Reference in New Issue
Block a user