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:
@@ -13,6 +13,7 @@
|
||||
import Stats from './screens/Stats.svelte';
|
||||
import Game from './game/Game.svelte';
|
||||
import CommsHub from './game/CommsHub.svelte';
|
||||
import Blocked from './screens/Blocked.svelte';
|
||||
|
||||
onMount(() => {
|
||||
void bootstrap();
|
||||
@@ -68,6 +69,8 @@
|
||||
|
||||
{#if !app.ready}
|
||||
<div class="splash">{t('common.loading')}</div>
|
||||
{:else if app.blocked}
|
||||
<Blocked />
|
||||
{:else}
|
||||
<div class="router">
|
||||
{#key routeKey}
|
||||
|
||||
@@ -4,6 +4,7 @@ export { AccountRef } from './scrabblefb/account-ref.js';
|
||||
export { Ack } from './scrabblefb/ack.js';
|
||||
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
|
||||
export { BlockList } from './scrabblefb/block-list.js';
|
||||
export { BlockStatus } from './scrabblefb/block-status.js';
|
||||
export { ChatList } from './scrabblefb/chat-list.js';
|
||||
export { ChatMessage } from './scrabblefb/chat-message.js';
|
||||
export { ChatPostRequest } from './scrabblefb/chat-post-request.js';
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class BlockStatus {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):BlockStatus {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsBlockStatus(bb:flatbuffers.ByteBuffer, obj?:BlockStatus):BlockStatus {
|
||||
return (obj || new BlockStatus()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsBlockStatus(bb:flatbuffers.ByteBuffer, obj?:BlockStatus):BlockStatus {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new BlockStatus()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
blocked():boolean {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||
}
|
||||
|
||||
permanent():boolean {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||
}
|
||||
|
||||
until():string|null
|
||||
until(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
until(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
reason():string|null
|
||||
reason(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
reason(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
static startBlockStatus(builder:flatbuffers.Builder) {
|
||||
builder.startObject(4);
|
||||
}
|
||||
|
||||
static addBlocked(builder:flatbuffers.Builder, blocked:boolean) {
|
||||
builder.addFieldInt8(0, +blocked, +false);
|
||||
}
|
||||
|
||||
static addPermanent(builder:flatbuffers.Builder, permanent:boolean) {
|
||||
builder.addFieldInt8(1, +permanent, +false);
|
||||
}
|
||||
|
||||
static addUntil(builder:flatbuffers.Builder, untilOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, untilOffset, 0);
|
||||
}
|
||||
|
||||
static addReason(builder:flatbuffers.Builder, reasonOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(3, reasonOffset, 0);
|
||||
}
|
||||
|
||||
static endBlockStatus(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createBlockStatus(builder:flatbuffers.Builder, blocked:boolean, permanent:boolean, untilOffset:flatbuffers.Offset, reasonOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
BlockStatus.startBlockStatus(builder);
|
||||
BlockStatus.addBlocked(builder, blocked);
|
||||
BlockStatus.addPermanent(builder, permanent);
|
||||
BlockStatus.addUntil(builder, untilOffset);
|
||||
BlockStatus.addReason(builder, reasonOffset);
|
||||
return BlockStatus.endBlockStatus(builder);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// gateway calls funnel through here so errors map to one user-facing toast and an
|
||||
// expired session logs out.
|
||||
|
||||
import type { LinkResult, Profile, PushEvent, Session } from './model';
|
||||
import type { BlockStatus, LinkResult, Profile, PushEvent, Session } from './model';
|
||||
import { gateway } from './gateway';
|
||||
import { GatewayError } from './client';
|
||||
import { navigate, router } from './router.svelte';
|
||||
@@ -43,6 +43,9 @@ export const app = $state<{
|
||||
streamAlive: boolean;
|
||||
session: Session | null;
|
||||
profile: Profile | null;
|
||||
/** The caller's active manual block, or null. When set, App.svelte replaces every screen with
|
||||
* the terminal blocked screen and all push/poll is stopped. */
|
||||
blocked: BlockStatus | null;
|
||||
toast: Toast | null;
|
||||
lastEvent: PushEvent | null;
|
||||
theme: ThemePref;
|
||||
@@ -65,6 +68,7 @@ export const app = $state<{
|
||||
streamAlive: false,
|
||||
session: null,
|
||||
profile: null,
|
||||
blocked: null,
|
||||
toast: null,
|
||||
lastEvent: null,
|
||||
theme: 'auto',
|
||||
@@ -140,11 +144,39 @@ export function handleError(err: unknown): void {
|
||||
void logout();
|
||||
return;
|
||||
}
|
||||
if (code === 'account_blocked') {
|
||||
void enterBlocked();
|
||||
return;
|
||||
}
|
||||
if (isConnectionCode(code) || !connection.online) return;
|
||||
telegramHaptic('error');
|
||||
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* enterBlocked switches the app to the terminal blocked screen and stops all push/poll. It is
|
||||
* triggered whenever any call reports the "account_blocked" code. It flips the screen
|
||||
* synchronously (a generic placeholder), then fetches the block's expiry and reason — the one
|
||||
* endpoint a blocked client may still reach — to render the precise message. If that fetch reports
|
||||
* the block was already lifted (a race), it recovers: clears the blocked screen and reopens the
|
||||
* stream.
|
||||
*/
|
||||
export async function enterBlocked(): Promise<void> {
|
||||
closeStream(); // stop the live stream; the blocked screen makes no further calls
|
||||
app.blocked ??= { blocked: true, permanent: true, until: '', reason: '' };
|
||||
try {
|
||||
const s = await gateway.blockStatus();
|
||||
if (s.blocked) {
|
||||
app.blocked = s;
|
||||
} else {
|
||||
app.blocked = null;
|
||||
if (app.session) openStream();
|
||||
}
|
||||
} catch {
|
||||
// Keep the placeholder blocked screen even if the details cannot be fetched.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* viewingGame reports whether the game board for the given game id is the current route. That screen
|
||||
* maintains its own cache from live events, so the global stream handler must not also advance it (a
|
||||
@@ -284,6 +316,8 @@ async function adoptSession(s: Session): Promise<void> {
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
// A blocked account stays on the blocked screen: no live stream, no notification poll.
|
||||
if (app.blocked) return;
|
||||
openStream();
|
||||
void refreshNotifications();
|
||||
}
|
||||
@@ -392,7 +426,8 @@ export async function bootstrap(): Promise<void> {
|
||||
telegramDisableVerticalSwipes();
|
||||
try {
|
||||
await adoptSession(await gateway.authTelegram(launch.initData));
|
||||
await routeStartParam(launch.startParam);
|
||||
// A blocked account skips deep-link routing — the blocked screen overlays every route.
|
||||
if (!app.blocked) await routeStartParam(launch.startParam);
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
navigate('/login');
|
||||
@@ -568,4 +603,10 @@ if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') {
|
||||
drop: closeStream,
|
||||
restore: openStream,
|
||||
};
|
||||
// Drive the terminal blocked screen from the e2e (the mock never blocks of its own accord). It
|
||||
// mirrors enterBlocked: flip the screen and stop the live stream.
|
||||
(window as unknown as { __block?: (b?: Partial<BlockStatus>) => void }).__block = (b) => {
|
||||
closeStream();
|
||||
app.blocked = { blocked: true, permanent: true, until: '', reason: '', ...b };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatBlockUntil } from './blocked';
|
||||
|
||||
describe('formatBlockUntil', () => {
|
||||
const instant = '2026-07-01T12:00:00Z';
|
||||
|
||||
it('formats a valid instant in the given timezone', () => {
|
||||
const out = formatBlockUntil(instant, 'en', 'UTC');
|
||||
expect(out).not.toBe(instant); // it was localized, not echoed
|
||||
expect(out).toContain('2026');
|
||||
});
|
||||
|
||||
it('shifts the rendered time by timezone', () => {
|
||||
const utc = formatBlockUntil(instant, 'en', 'UTC');
|
||||
const ny = formatBlockUntil(instant, 'en', 'America/New_York');
|
||||
expect(ny).not.toBe(utc); // 12:00 UTC renders as a different wall-clock in New York
|
||||
});
|
||||
|
||||
it('falls back to the raw string for an unparseable instant', () => {
|
||||
expect(formatBlockUntil('not-a-date', 'en', 'UTC')).toBe('not-a-date');
|
||||
});
|
||||
|
||||
it('does not throw on an invalid timezone, still rendering the date', () => {
|
||||
const out = formatBlockUntil(instant, 'en', 'Not/AZone');
|
||||
expect(out).toContain('2026');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// Pure helpers for the terminal blocked screen, kept out of the .svelte component so they are
|
||||
// unit-testable in the node vitest env.
|
||||
|
||||
import type { Locale } from './i18n/index.svelte';
|
||||
|
||||
/**
|
||||
* formatBlockUntil renders a temporary block's RFC3339 UTC expiry in the user's locale and
|
||||
* timezone, e.g. "1 Jul 2026, 15:00". It falls back to UTC formatting when the timezone is
|
||||
* unknown/invalid, and to the raw string when the instant cannot be parsed at all.
|
||||
*/
|
||||
export function formatBlockUntil(until: string, locale: Locale, timeZone: string): string {
|
||||
const d = new Date(until);
|
||||
if (Number.isNaN(d.getTime())) return until;
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
timeZone: timeZone || undefined,
|
||||
}).format(d);
|
||||
} catch {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d);
|
||||
} catch {
|
||||
return until;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type {
|
||||
AccountRef,
|
||||
BlockStatus,
|
||||
ChatMessage,
|
||||
EvalResult,
|
||||
FriendCode,
|
||||
@@ -60,6 +61,9 @@ export interface GatewayClient {
|
||||
|
||||
// --- profile / lists ---
|
||||
profileGet(): Promise<Profile>;
|
||||
/** The caller's current manual block. Exempt from the backend's suspension gate, so a blocked
|
||||
* client can still fetch it to render the blocked screen. */
|
||||
blockStatus(): Promise<BlockStatus>;
|
||||
gamesList(): Promise<GameList>;
|
||||
|
||||
// --- lobby ---
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import * as fb from '../gen/fbs/scrabblefb';
|
||||
import { BLANK_INDEX, setAlphabet } from './alphabet';
|
||||
import {
|
||||
decodeBlockStatus,
|
||||
decodeDraftView,
|
||||
decodeEvent,
|
||||
decodeFriendList,
|
||||
@@ -37,6 +38,38 @@ describe('codec', () => {
|
||||
expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}');
|
||||
});
|
||||
|
||||
it('decodes a temporary BlockStatus with reason', () => {
|
||||
const b = new Builder(64);
|
||||
const until = b.createString('2026-07-01T12:00:00Z');
|
||||
const reason = b.createString('Спам');
|
||||
fb.BlockStatus.startBlockStatus(b);
|
||||
fb.BlockStatus.addBlocked(b, true);
|
||||
fb.BlockStatus.addPermanent(b, false);
|
||||
fb.BlockStatus.addUntil(b, until);
|
||||
fb.BlockStatus.addReason(b, reason);
|
||||
b.finish(fb.BlockStatus.endBlockStatus(b));
|
||||
expect(decodeBlockStatus(b.asUint8Array())).toEqual({
|
||||
blocked: true,
|
||||
permanent: false,
|
||||
until: '2026-07-01T12:00:00Z',
|
||||
reason: 'Спам',
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes a permanent BlockStatus with empty until/reason', () => {
|
||||
const b = new Builder(32);
|
||||
fb.BlockStatus.startBlockStatus(b);
|
||||
fb.BlockStatus.addBlocked(b, true);
|
||||
fb.BlockStatus.addPermanent(b, true);
|
||||
b.finish(fb.BlockStatus.endBlockStatus(b));
|
||||
expect(decodeBlockStatus(b.asUint8Array())).toEqual({
|
||||
blocked: true,
|
||||
permanent: true,
|
||||
until: '',
|
||||
reason: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('encodes a SubmitPlayRequest with alphabet indices', () => {
|
||||
setAlphabet('scrabble_en', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
|
||||
@@ -9,6 +9,7 @@ import { indexForLetter, letterForIndex, setAlphabet, type AlphabetEntryWire } f
|
||||
import type { PlacedTile } from './client';
|
||||
import type {
|
||||
AccountRef,
|
||||
BlockStatus,
|
||||
ChatMessage,
|
||||
EvalResult,
|
||||
FriendCode,
|
||||
@@ -314,6 +315,16 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeBlockStatus(buf: Uint8Array): BlockStatus {
|
||||
const b = fb.BlockStatus.getRootAsBlockStatus(new ByteBuffer(buf));
|
||||
return {
|
||||
blocked: b.blocked(),
|
||||
permanent: b.permanent(),
|
||||
until: s(b.until()),
|
||||
reason: s(b.reason()),
|
||||
};
|
||||
}
|
||||
|
||||
// decodeStateViewTable projects a StateView table (a root or one nested in an event) to the
|
||||
// model. It caches the alphabet when present (a per-variant cache miss) and decodes the index
|
||||
// rack to display letters with it.
|
||||
|
||||
@@ -6,6 +6,11 @@ export const en = {
|
||||
'app.title': 'Scrabble',
|
||||
'connection.connecting': 'Connecting…',
|
||||
|
||||
'blocked.title': 'Account blocked',
|
||||
'blocked.permanent': 'Your account is blocked.',
|
||||
'blocked.temporary': 'Your account is blocked until {until}.',
|
||||
'blocked.reason': 'Reason:',
|
||||
|
||||
'common.back': 'Back',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.ok': 'OK',
|
||||
|
||||
@@ -7,6 +7,11 @@ export const ru: Record<MessageKey, string> = {
|
||||
'app.title': 'Scrabble',
|
||||
'connection.connecting': 'Подключение…',
|
||||
|
||||
'blocked.title': 'Учётная запись заблокирована',
|
||||
'blocked.permanent': 'Ваша учётная запись заблокирована.',
|
||||
'blocked.temporary': 'Ваша учётная запись заблокирована до {until}.',
|
||||
'blocked.reason': 'Причина:',
|
||||
|
||||
'common.back': 'Назад',
|
||||
'common.cancel': 'Отмена',
|
||||
'common.ok': 'ОК',
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { GatewayError } from '../client';
|
||||
import type {
|
||||
AccountRef,
|
||||
BlockStatus,
|
||||
ChatMessage,
|
||||
EvalResult,
|
||||
FriendCode,
|
||||
@@ -139,6 +140,10 @@ export class MockGateway implements GatewayClient {
|
||||
async profileGet(): Promise<Profile> {
|
||||
return { ...this.profile };
|
||||
}
|
||||
async blockStatus(): Promise<BlockStatus> {
|
||||
// The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam.
|
||||
return { blocked: false, permanent: false, until: '', reason: '' };
|
||||
}
|
||||
async gamesList(): Promise<GameList> {
|
||||
return { games: [...this.games.values()].map((g) => structuredClone(g.view)) };
|
||||
}
|
||||
|
||||
@@ -121,6 +121,18 @@ export interface Profile {
|
||||
notificationsInAppOnly: boolean;
|
||||
}
|
||||
|
||||
/** BlockStatus is the caller's current manual block, fetched after any call reports
|
||||
* the "account_blocked" code. When blocked the app shows the terminal blocked screen
|
||||
* and stops all push/poll. */
|
||||
export interface BlockStatus {
|
||||
blocked: boolean;
|
||||
permanent: boolean;
|
||||
/** RFC3339 UTC instant for a temporary block; "" for a permanent one or when not blocked. */
|
||||
until: string;
|
||||
/** Operator reason resolved to the account's language; "" when none was cited. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** The full editable profile sent to profileUpdate (overwrites every field). */
|
||||
export interface ProfileUpdate {
|
||||
displayName: string;
|
||||
|
||||
@@ -77,6 +77,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async profileGet() {
|
||||
return codec.decodeProfile(await exec('profile.get', codec.empty()));
|
||||
},
|
||||
async blockStatus() {
|
||||
return codec.decodeBlockStatus(await exec('account.block_status', codec.empty()));
|
||||
},
|
||||
async gamesList() {
|
||||
return codec.decodeGameList(await exec('games.list', codec.empty()));
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user