fix(ui): retry Mini App launch on backend failure; hide account linking
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
Inside Telegram, a failed initData authentication (e.g. the backend down during a deploy) dropped the user onto the web login screen — the /app/ experience, which has no place inside the Mini App. bootstrap now retries the launch a few times in silence and then renders a dedicated boot-error screen with a Retry button (new BootError.svelte, app.bootError), never falling back to the web sign-in. A blocked account is still terminal and goes straight to the blocked screen. The profile "Link an account" section (email + Telegram link) is hidden while sign-in is provider-only; the anonymous /app/ guest whose upgrade path this is comes later. The flow is kept wired (`hidden` on .emailbox) and its two e2e specs are skipped, both to be re-enabled together. Adds i18n boot.* copy (en/ru), a mock authTelegram failure hook plus an e2e covering the retry screen, and bakes both behaviours into FUNCTIONAL(.md/_ru).
This commit is contained in:
+8
-1
@@ -31,7 +31,10 @@ ephemeral guest. The gateway validates the credential once and mints a thin
|
|||||||
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
|
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
|
||||||
App** launch authenticates from the platform's signed `initData`, themes the UI to
|
App** launch authenticates from the platform's signed `initData`, themes the UI to
|
||||||
the Telegram colours, and — on first contact — seeds the new account's interface
|
the Telegram colours, and — on first contact — seeds the new account's interface
|
||||||
language from the Telegram client. Telegram runs a **single bot**: every player uses
|
language from the Telegram client. If a launch cannot reach the backend (for example during a
|
||||||
|
deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a
|
||||||
|
**Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram.
|
||||||
|
Telegram runs a **single bot**: every player uses
|
||||||
the same bot, and all of its chat and out-of-app notifications are written in the
|
the same bot, and all of its chat and out-of-app notifications are written in the
|
||||||
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
||||||
main one — its only job is to answer `/start` with a short message and a button that opens the
|
main one — its only job is to answer `/start` with a short message and a button that opens the
|
||||||
@@ -56,6 +59,10 @@ reconnect), and pending reads resume on their own — the interface stays usable
|
|||||||
flashing a red banner each time.
|
flashing a red banner each time.
|
||||||
|
|
||||||
### Accounts, linking & merge
|
### Accounts, linking & merge
|
||||||
|
_Sign-in is currently provider-only, so the in-profile linking UI is temporarily hidden; it
|
||||||
|
returns once the anonymous `/app/` guest (whose upgrade path this is) ships. The flow below
|
||||||
|
describes it for when it does._
|
||||||
|
|
||||||
First platform contact auto-provisions a durable account. From the profile a player
|
First platform contact auto-provisions a durable account. From the profile a player
|
||||||
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
|
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
|
||||||
who links their first identity becomes a durable account. The "already taken" status
|
who links their first identity becomes a durable account. The "already taken" status
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
|
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
|
||||||
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
|
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
|
||||||
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
|
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
|
||||||
языку Telegram-клиента. Telegram держит **единого бота**: все игроки пользуются одним
|
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
|
||||||
|
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
|
||||||
|
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
|
||||||
|
Telegram не место. Telegram держит **единого бота**: все игроки пользуются одним
|
||||||
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
||||||
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
||||||
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
|
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
|
||||||
@@ -57,6 +60,10 @@ Mini App** авторизует по подписанным `initData` плат
|
|||||||
рабочим вместо красного баннера каждый раз.
|
рабочим вместо красного баннера каждый раз.
|
||||||
|
|
||||||
### Аккаунты, привязка и слияние
|
### Аккаунты, привязка и слияние
|
||||||
|
_Вход сейчас только через провайдера, поэтому UI привязки в профиле временно скрыт; он
|
||||||
|
вернётся, когда появится анонимный `/app/`-гость (для апгрейда которого он и нужен). Описание
|
||||||
|
ниже — на этот случай._
|
||||||
|
|
||||||
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
|
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
|
||||||
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
|
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
|
||||||
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
|
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
|
||||||
|
|||||||
@@ -238,7 +238,10 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa
|
|||||||
await expect(save).toBeEnabled();
|
await expect(save).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => {
|
// Account linking is hidden in Profile.svelte while we target provider sign-in (the anonymous
|
||||||
|
// /app/ guest who upgrades by linking comes later). The flow is kept wired; re-enable these two
|
||||||
|
// specs together with the `.emailbox` section.
|
||||||
|
test.skip('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await openProfile(page);
|
await openProfile(page);
|
||||||
|
|
||||||
@@ -258,7 +261,7 @@ test('link account: a taken email opens the irreversible merge confirmation', as
|
|||||||
await expect(page.getByText('Merge accounts?')).toBeHidden();
|
await expect(page.getByText('Merge accounts?')).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('link account: the Telegram web sign-in control is offered in a browser', async ({ page }) => {
|
test.skip('link account: the Telegram web sign-in control is offered in a browser', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await openProfile(page);
|
await openProfile(page);
|
||||||
await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible();
|
||||||
|
|||||||
@@ -83,6 +83,30 @@ test('tg-fullscreen header keeps a constant native-nav gap as the font scales',
|
|||||||
expect(large.overflows).toBe(false);
|
expect(large.overflows).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('inside Telegram, a failed launch shows the retry screen, not the web login', async ({ page }) => {
|
||||||
|
// initData carrying the mock's "bootfail" sentinel makes authTelegram reject, simulating a
|
||||||
|
// backend outage during launch (e.g. a deploy rolling). The Mini App must surface its own
|
||||||
|
// boot-error/retry screen and never fall back to the web (guest/email) login.
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
Object.assign(window, {
|
||||||
|
Telegram: {
|
||||||
|
WebApp: {
|
||||||
|
initData: 'query_id=bootfail&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef',
|
||||||
|
initDataUnsafe: {},
|
||||||
|
ready() {},
|
||||||
|
expand() {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// After the silent retries, the boot-error screen with its Retry button shows…
|
||||||
|
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
|
||||||
|
// …and the web login (guest) is never shown inside Telegram.
|
||||||
|
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('outside Telegram, the /telegram/ entry redirects to the site root', async ({ page }) => {
|
test('outside Telegram, the /telegram/ entry redirects to the site root', async ({ page }) => {
|
||||||
await page.goto('/telegram/');
|
await page.goto('/telegram/');
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -18,6 +18,7 @@
|
|||||||
import CommsHub from './game/CommsHub.svelte';
|
import CommsHub from './game/CommsHub.svelte';
|
||||||
import Feedback from './screens/Feedback.svelte';
|
import Feedback from './screens/Feedback.svelte';
|
||||||
import Blocked from './screens/Blocked.svelte';
|
import Blocked from './screens/Blocked.svelte';
|
||||||
|
import BootError from './screens/BootError.svelte';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
@@ -83,6 +84,10 @@
|
|||||||
{#if !routeIsLobby}
|
{#if !routeIsLobby}
|
||||||
<div class="splash">{t('common.loading')}</div>
|
<div class="splash">{t('common.loading')}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if app.bootError}
|
||||||
|
<!-- A Mini App launch that failed to authenticate (e.g. the backend was down mid-deploy):
|
||||||
|
show the retry screen instead of falling back to the web login. -->
|
||||||
|
<BootError />
|
||||||
{:else if app.blocked}
|
{:else if app.blocked}
|
||||||
<Blocked />
|
<Blocked />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -123,7 +128,7 @@
|
|||||||
<StaleInviteModal />
|
<StaleInviteModal />
|
||||||
<WelcomeRedeemModal />
|
<WelcomeRedeemModal />
|
||||||
|
|
||||||
{#if routeIsLobby && !app.splashDone && !app.blocked}
|
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError}
|
||||||
<Splash />
|
<Splash />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
telegramDisableVerticalSwipes,
|
telegramDisableVerticalSwipes,
|
||||||
telegramHaptic,
|
telegramHaptic,
|
||||||
telegramLaunch,
|
telegramLaunch,
|
||||||
|
type TelegramLaunch,
|
||||||
telegramOnEvent,
|
telegramOnEvent,
|
||||||
telegramRequestFullscreen,
|
telegramRequestFullscreen,
|
||||||
telegramSetChrome,
|
telegramSetChrome,
|
||||||
@@ -41,6 +42,10 @@ export interface Toast {
|
|||||||
|
|
||||||
export const app = $state<{
|
export const app = $state<{
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
|
/** Inside a Mini App, set when the launch failed to authenticate after its retries (e.g. the
|
||||||
|
* backend was down during a deploy). App.svelte then renders the boot-error retry screen
|
||||||
|
* instead of the web login — a Mini App has no manual sign-in to fall back to. */
|
||||||
|
bootError: boolean;
|
||||||
/** Whether the lobby's first cold load has settled (success or error). The loading splash
|
/** Whether the lobby's first cold load has settled (success or error). The loading splash
|
||||||
* (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */
|
* (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */
|
||||||
lobbyReady: boolean;
|
lobbyReady: boolean;
|
||||||
@@ -90,6 +95,7 @@ export const app = $state<{
|
|||||||
resync: number;
|
resync: number;
|
||||||
}>({
|
}>({
|
||||||
ready: false,
|
ready: false,
|
||||||
|
bootError: false,
|
||||||
lobbyReady: false,
|
lobbyReady: false,
|
||||||
splashDone: false,
|
splashDone: false,
|
||||||
streamAlive: false,
|
streamAlive: false,
|
||||||
@@ -563,14 +569,7 @@ export async function bootstrap(): Promise<void> {
|
|||||||
// listener above then re-syncs the safe-area insets. Desktop keeps the bot's full-size
|
// listener above then re-syncs the safe-area insets. Desktop keeps the bot's full-size
|
||||||
// window. No-op on clients predating Bot API 8.0.
|
// window. No-op on clients predating Bot API 8.0.
|
||||||
telegramRequestFullscreen();
|
telegramRequestFullscreen();
|
||||||
try {
|
await bootTelegram(launch);
|
||||||
await adoptSession(await gateway.authTelegram(launch.initData));
|
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
app.ready = true;
|
app.ready = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -585,6 +584,57 @@ export async function bootstrap(): Promise<void> {
|
|||||||
app.ready = true;
|
app.ready = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inside a Mini App the only identity is the Telegram session, so a failed launch must never fall
|
||||||
|
// back to the web login screen. A transient backend outage (a deploy rolling over) is retried a
|
||||||
|
// few times in silence; only then does the boot-error screen surface, from which Retry re-runs the
|
||||||
|
// same path (retryTelegramBoot).
|
||||||
|
const TELEGRAM_BOOT_RETRIES = 2;
|
||||||
|
const TELEGRAM_BOOT_RETRY_MS = 1200;
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bootTelegram authenticates a Mini App launch from its initData and routes any deep-link start
|
||||||
|
* parameter, retrying a few times on a transient failure before raising the boot-error screen
|
||||||
|
* (app.bootError). A blocked account is terminal — it switches straight to the blocked screen
|
||||||
|
* without retrying.
|
||||||
|
*/
|
||||||
|
async function bootTelegram(launch: TelegramLaunch): Promise<void> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await adoptSession(await gateway.authTelegram(launch.initData));
|
||||||
|
// A blocked account skips deep-link routing — the blocked screen overlays every route.
|
||||||
|
if (!app.blocked) await routeStartParam(launch.startParam);
|
||||||
|
app.bootError = false;
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof GatewayError && err.code === 'account_blocked') {
|
||||||
|
await enterBlocked();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempt >= TELEGRAM_BOOT_RETRIES) {
|
||||||
|
app.bootError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await delay(TELEGRAM_BOOT_RETRY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* retryTelegramBoot re-attempts the Mini App launch from the boot-error screen's Retry button. It
|
||||||
|
* clears the error and shows the loading state again, then runs the same retrying boot; on success
|
||||||
|
* the app renders normally, otherwise the boot-error screen returns.
|
||||||
|
*/
|
||||||
|
export async function retryTelegramBoot(): Promise<void> {
|
||||||
|
app.bootError = false;
|
||||||
|
app.ready = false;
|
||||||
|
await bootTelegram(telegramLaunch());
|
||||||
|
app.ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* routeStartParam navigates a Telegram deep-link start parameter to its target: a
|
* routeStartParam navigates a Telegram deep-link start parameter to its target: a
|
||||||
* specific game, the friends screen with a friend-code redemption, or the lobby
|
* specific game, the friends screen with a friend-code redemption, or the lobby
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export const en = {
|
|||||||
'blocked.temporary': 'Your account is blocked until {until}.',
|
'blocked.temporary': 'Your account is blocked until {until}.',
|
||||||
'blocked.reason': 'Reason:',
|
'blocked.reason': 'Reason:',
|
||||||
|
|
||||||
|
'boot.errorTitle': "Couldn't load the game",
|
||||||
|
'boot.errorBody': 'Please try again in a moment.',
|
||||||
|
|
||||||
'common.back': 'Back',
|
'common.back': 'Back',
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
'common.ok': 'OK',
|
'common.ok': 'OK',
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'blocked.temporary': 'Ваша учётная запись заблокирована до {until}.',
|
'blocked.temporary': 'Ваша учётная запись заблокирована до {until}.',
|
||||||
'blocked.reason': 'Причина:',
|
'blocked.reason': 'Причина:',
|
||||||
|
|
||||||
|
'boot.errorTitle': 'Не удалось загрузить игру',
|
||||||
|
'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.',
|
||||||
|
|
||||||
'common.back': 'Назад',
|
'common.back': 'Назад',
|
||||||
'common.cancel': 'Отмена',
|
'common.cancel': 'Отмена',
|
||||||
'common.ok': 'ОК',
|
'common.ok': 'ОК',
|
||||||
|
|||||||
@@ -136,7 +136,10 @@ export class MockGateway implements GatewayClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- auth ---
|
// --- auth ---
|
||||||
async authTelegram(): Promise<Session> {
|
async authTelegram(initData: string): Promise<Session> {
|
||||||
|
// e2e hook: an initData carrying this sentinel simulates a backend that rejects the launch,
|
||||||
|
// so the Mini App boot-failure path (silent retries → boot-error screen) can be exercised.
|
||||||
|
if (initData.includes('bootfail')) throw new GatewayError('unavailable');
|
||||||
return { ...SESSION, isGuest: false };
|
return { ...SESSION, isGuest: false };
|
||||||
}
|
}
|
||||||
async authGuest(): Promise<Session> {
|
async authGuest(): Promise<Session> {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The Mini App launch failed to authenticate after its silent retries (e.g. the backend was
|
||||||
|
// briefly down during a deploy). Inside Telegram there is no web login to fall back to, so this
|
||||||
|
// terminal screen offers a manual Retry that re-runs the launch (app.svelte retryTelegramBoot).
|
||||||
|
import { retryTelegramBoot } from '../lib/app.svelte';
|
||||||
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
|
||||||
|
let retrying = $state(false);
|
||||||
|
async function retry(): Promise<void> {
|
||||||
|
if (retrying) return;
|
||||||
|
retrying = true;
|
||||||
|
try {
|
||||||
|
await retryTelegramBoot();
|
||||||
|
} finally {
|
||||||
|
retrying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="boot">
|
||||||
|
<div class="card">
|
||||||
|
<h1>{t('boot.errorTitle')}</h1>
|
||||||
|
<p class="msg">{t('boot.errorBody')}</p>
|
||||||
|
<button class="retry" onclick={retry} disabled={retrying}>{t('common.retry')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.boot {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
max-width: 28rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.retry {
|
||||||
|
padding: 9px 16px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.retry:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -244,9 +244,10 @@
|
|||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Linking & merge. Shown to everyone, including guests, who
|
<!-- Linking & merge. Hidden for now: we target provider sign-in, and the anonymous
|
||||||
upgrade by binding their first identity. -->
|
/app/ guest (whose upgrade path this is) comes later. Kept wired — drop `hidden`
|
||||||
<section class="emailbox">
|
to re-enable, together with the skipped linking specs in e2e/social.spec.ts. -->
|
||||||
|
<section class="emailbox" hidden>
|
||||||
<h3>{t('profile.linkAccount')}</h3>
|
<h3>{t('profile.linkAccount')}</h3>
|
||||||
{#if !emailSent}
|
{#if !emailSent}
|
||||||
<div class="addrow">
|
<div class="addrow">
|
||||||
|
|||||||
Reference in New Issue
Block a user