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:
@@ -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 };
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user