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
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
// The terminal blocked screen. While app.blocked is set, App.svelte renders only this and all
// push/poll is stopped; the screen itself makes no network calls. The message is permanent or
// dated (the expiry rendered in the user's locale + timezone), with the optional operator reason.
import { app } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import { formatBlockUntil } from '../lib/blocked';
const until = $derived(
app.blocked?.until ? formatBlockUntil(app.blocked.until, app.locale, app.profile?.timeZone ?? '') : '',
);
const message = $derived(until ? t('blocked.temporary', { until }) : t('blocked.permanent'));
</script>
<div class="blocked">
<div class="card">
<h1>{t('blocked.title')}</h1>
<p class="msg">{message}</p>
{#if app.blocked?.reason}
<p class="reason"><span class="label">{t('blocked.reason')}</span> {app.blocked.reason}</p>
{/if}
</div>
</div>
<style>
.blocked {
height: 100%;
display: grid;
place-items: center;
padding: 24px;
background: var(--bg);
}
.card {
max-width: 28rem;
text-align: center;
color: var(--text);
}
h1 {
margin: 0 0 0.75rem;
font-size: 1.25rem;
}
.msg {
margin: 0;
color: var(--text);
}
.reason {
margin: 1rem 0 0;
color: var(--text-muted);
}
.label {
font-weight: 600;
}
</style>