feat(profile): account-deletion flow + terminal deleted screen

Profile gains a Delete-account control (durable accounts) opening a step-up dialog: a
mailed code for an email account, or the typed DELETE phrase for a platform-only one.
On success the app swaps to a terminal AccountDeleted screen ('Учётная запись удалена')
with a Close that closes the host Mini App (telegramClose / vkClose; web = no close).
Wires deleteRequest/deleteConfirm through client/transport/mock/codec; ru/en i18n;
codec wire test + Chromium/WebKit e2e.
This commit is contained in:
Ilia Denisov
2026-07-03 13:18:37 +02:00
parent aa2290b7b4
commit cabcd94d92
14 changed files with 267 additions and 2 deletions
+14
View File
@@ -345,6 +345,20 @@ test('link then unlink Telegram from the sign-in methods', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible();
});
test('account deletion: the mailed-code step-up leads to the terminal deleted screen', async ({ page }) => {
await loginLobby(page);
await openProfile(page);
// The mock profile holds an email, so deletion asks for the mailed code.
await page.getByRole('button', { name: 'Delete account' }).click();
await expect(page.getByText('Delete your account?')).toBeVisible();
await page.getByRole('dialog').locator('.codein').fill('123456');
await page.getByRole('dialog').getByRole('button', { name: 'Delete permanently' }).click();
// The app swaps to the terminal account-deleted screen.
await expect(page.getByText('Account deleted')).toBeVisible();
});
test('chat: one message per turn — the field shows, then a caption replaces it after sending', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn
+3
View File
@@ -20,6 +20,7 @@
import CommsHub from './game/CommsHub.svelte';
import Feedback from './screens/Feedback.svelte';
import Blocked from './screens/Blocked.svelte';
import AccountDeleted from './screens/AccountDeleted.svelte';
import BootError from './screens/BootError.svelte';
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
@@ -83,6 +84,8 @@
<!-- 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.accountDeleted}
<AccountDeleted />
{:else if app.blocked}
<Blocked />
{:else}
+39 -1
View File
@@ -13,6 +13,7 @@ import { startLocalEvalMetrics } from './localeval-metrics';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme';
import {
insideTelegram,
telegramClose,
collectTelegramDiag,
type TelegramDiag,
onTelegramPath,
@@ -32,7 +33,7 @@ import {
telegramCloudGet,
telegramCloudSet,
} from './telegram';
import { onVKPath, insideVK, vkInit, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
import { haptic } from './haptics';
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
import { parseStartParam } from './deeplink';
@@ -92,6 +93,9 @@ export const app = $state<{
/** 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;
/** Set once the account has been deleted: App.svelte shows the terminal "account deleted"
* screen and all push/poll is stopped. Reopening the app just creates a fresh account. */
accountDeleted: boolean;
toast: Toast | null;
lastEvent: PushEvent | null;
theme: ThemePref;
@@ -140,6 +144,7 @@ export const app = $state<{
session: null,
profile: null,
blocked: null,
accountDeleted: false,
toast: null,
lastEvent: null,
theme: 'auto',
@@ -544,6 +549,39 @@ export async function applyLinkResult(r: LinkResult): Promise<void> {
void persistLanguageToServer(app.locale);
}
/**
* requestDeleteAccount starts account deletion and reports which step-up the account uses:
* 'email' (a code was mailed) or 'phrase' (type the confirmation phrase).
*/
export async function requestDeleteAccount(): Promise<'email' | 'phrase'> {
return (await gateway.deleteRequest()).method;
}
/**
* confirmDeleteAccount confirms deletion with the mailed code or the typed phrase. On
* success it switches to the terminal "account deleted" screen and stops all push/poll; it
* throws on a bad code/phrase so the caller can let the user retry.
*/
export async function confirmDeleteAccount(code: string, phrase: string): Promise<void> {
await gateway.deleteConfirm(code, phrase);
closeStream();
app.accountDeleted = true;
}
/**
* closeDeletedApp closes the host Mini App from the terminal deleted screen (Telegram / VK).
* A plain browser has nothing to close, so it is a no-op there — the terminal screen stays.
*/
export function closeDeletedApp(): void {
if (insideTelegram()) {
telegramClose();
return;
}
if (insideVK()) {
void vkClose();
}
}
/**
* syncTelegramChrome paints Telegram's header/background/bottom bar from the app's live
* theme tokens, so the surrounding chrome matches the UI. Called after the theme is applied.
+5
View File
@@ -185,6 +185,11 @@ export interface GatewayClient {
* refused without disclosure. */
changeEmailRequest(email: string): Promise<void>;
changeEmailConfirm(email: string, code: string): Promise<LinkResult>;
/** Start account deletion; the backend mails a code (method 'email') or asks for the
* typed phrase (method 'phrase'). */
deleteRequest(): Promise<{ method: 'email' | 'phrase' }>;
/** Confirm and perform account deletion with the mailed code or the typed phrase. */
deleteConfirm(code: string, phrase: string): Promise<void>;
// --- live stream ---
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
+17
View File
@@ -27,6 +27,8 @@ import {
encodeEnqueue,
encodeExchange,
encodeExportUrlRequest,
encodeAccountDeleteConfirm,
decodeDeleteRequestResult,
encodeGuestLogin,
encodeLinkUnlink,
encodeStateRequest,
@@ -464,6 +466,21 @@ describe('codec', () => {
expect(r.kind()).toBe('telegram');
});
it('round-trips the account-delete confirm + request-result wire', () => {
const c = fb.AccountDeleteConfirm.getRootAsAccountDeleteConfirm(
new ByteBuffer(encodeAccountDeleteConfirm('123456', 'DELETE')),
);
expect(c.code()).toBe('123456');
expect(c.phrase()).toBe('DELETE');
const b = new Builder(32);
const m = b.createString('email');
fb.AccountDeleteRequestResult.startAccountDeleteRequestResult(b);
fb.AccountDeleteRequestResult.addMethod(b, m);
b.finish(fb.AccountDeleteRequestResult.endAccountDeleteRequestResult(b));
expect(decodeDeleteRequestResult(b.asUint8Array()).method).toBe('email');
});
it('passes an unlinked / changed LinkResult status straight through', () => {
for (const status of ['unlinked', 'changed'] as const) {
const b = new Builder(64);
+15
View File
@@ -739,6 +739,21 @@ export function encodeLinkUnlink(kind: string): Uint8Array {
return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b));
}
export function encodeAccountDeleteConfirm(code: string, phrase: string): Uint8Array {
const b = new Builder(64);
const c = b.createString(code);
const p = b.createString(phrase);
fb.AccountDeleteConfirm.startAccountDeleteConfirm(b);
fb.AccountDeleteConfirm.addCode(b, c);
fb.AccountDeleteConfirm.addPhrase(b, p);
return finish(b, fb.AccountDeleteConfirm.endAccountDeleteConfirm(b));
}
export function decodeDeleteRequestResult(buf: Uint8Array): { method: 'email' | 'phrase' } {
const r = fb.AccountDeleteRequestResult.getRootAsAccountDeleteRequestResult(new ByteBuffer(buf));
return { method: (s(r.method()) || 'phrase') as 'email' | 'phrase' };
}
export function decodeLinkResult(buf: Uint8Array): LinkResult {
const r = fb.LinkResult.getRootAsLinkResult(new ByteBuffer(buf));
const sess = r.session();
+9
View File
@@ -188,6 +188,15 @@ export const en = {
'profile.unlinked': 'Account unlinked.',
'profile.unlinkTitle': 'Unlink account?',
'profile.unlinkBody': 'Remove {provider} as a sign-in method? You can link it again later.',
'profile.deleteAccount': 'Delete account',
'profile.deleteTitle': 'Delete your account?',
'profile.deleteWarn': 'This removes your account and frees your sign-in methods. It cannot be undone.',
'profile.deleteEmailPrompt': 'We sent a code to your email. Enter it to delete your account.',
'profile.deletePhrasePrompt': 'Type DELETE to confirm.',
'profile.deletePhrasePlaceholder': 'DELETE',
'profile.deleteConfirm': 'Delete permanently',
'deleted.title': 'Account deleted',
'deleted.body': 'Your account has been removed. Reopening the app just creates a new account.',
'settings.title': 'Settings',
'settings.theme': 'Theme',
+9
View File
@@ -188,6 +188,15 @@ export const ru: Record<MessageKey, string> = {
'profile.unlinked': 'Аккаунт отвязан.',
'profile.unlinkTitle': 'Отвязать аккаунт?',
'profile.unlinkBody': 'Убрать {provider} как способ входа? Позже можно привязать снова.',
'profile.deleteAccount': 'Удалить аккаунт',
'profile.deleteTitle': 'Удалить аккаунт?',
'profile.deleteWarn': 'Аккаунт будет удалён, а способы входа освобождены. Отменить нельзя.',
'profile.deleteEmailPrompt': 'Мы отправили код на вашу почту. Введите его, чтобы удалить аккаунт.',
'profile.deletePhrasePrompt': 'Введите DELETE для подтверждения.',
'profile.deletePhrasePlaceholder': 'DELETE',
'profile.deleteConfirm': 'Удалить навсегда',
'deleted.title': 'Учётная запись удалена',
'deleted.body': 'Аккаунт удалён. Если снова откроете приложение — будет создан новый аккаунт.',
'settings.title': 'Настройки',
'settings.theme': 'Тема',
+4
View File
@@ -687,6 +687,10 @@ export class MockGateway implements GatewayClient {
this.profile.email = email;
return { ...emptyLinked(), status: 'changed' };
}
async deleteRequest(): Promise<{ method: 'email' | 'phrase' }> {
return { method: this.profile.email ? 'email' : 'phrase' };
}
async deleteConfirm(_code: string, _phrase: string): Promise<void> {}
async statsGet(): Promise<Stats> {
return { ...this.stats };
}
+7
View File
@@ -36,6 +36,7 @@ interface TelegramWebApp {
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
ready?: () => void;
expand?: () => void;
close?: () => void;
openTelegramLink?: (url: string) => void;
openLink?: (url: string) => void;
onEvent?: (event: string, handler: () => void) => void;
@@ -84,6 +85,12 @@ export function insideTelegram(): boolean {
return !!w && typeof w.initData === 'string' && w.initData.length > 0;
}
/** telegramClose closes the Mini App (Telegram.WebApp.close), used on the terminal
* account-deleted screen. A no-op outside Telegram. */
export function telegramClose(): void {
webApp()?.close?.();
}
// The ?NN suffix pins the Bot API SDK version Telegram serves (and busts the cache); keep it at the
// version the official Mini Apps page currently recommends so newer client features (fullscreen,
// safe-area insets, vertical-swipe guard, …) are available. Bump it when Telegram bumps theirs.
+6
View File
@@ -277,6 +277,12 @@ export function createTransport(baseUrl: string): GatewayClient {
async changeEmailConfirm(email, code) {
return codec.decodeLinkResult(await exec('link.email.change.confirm', codec.encodeLinkEmailConfirm(email, code)));
},
async deleteRequest() {
return codec.decodeDeleteRequestResult(await exec('account.delete.request', codec.empty()));
},
async deleteConfirm(code, phrase) {
await exec('account.delete.confirm', codec.encodeAccountDeleteConfirm(code, phrase));
},
async statsGet() {
return codec.decodeStats(await exec('stats.get', codec.empty()));
},
+12
View File
@@ -52,6 +52,18 @@ export async function vkInit(): Promise<void> {
}
}
/**
* vkClose closes the VK Mini App (VKWebAppClose), used on the terminal account-deleted
* screen. Best-effort: a no-op outside VK.
*/
export async function vkClose(): Promise<void> {
try {
await (await bridge()).send('VKWebAppClose', { status: 'success' });
} catch {
// Outside VK there is no client to receive it.
}
}
/**
* vkUserName fetches the launching user's display name via VKWebAppGetUserInfo, since VK omits it
* from the signed launch params. Returns '' on any failure or outside VK, so the backend falls back
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
// The terminal account-deleted screen. While app.accountDeleted is set, App.svelte renders
// only this and all push/poll is stopped; the screen makes no network calls. Reopening the
// app just creates a fresh account. The Close button closes the host Mini App (Telegram /
// VK); a plain browser has nothing to close, so the button is hidden there.
import { closeDeletedApp } from '../lib/app.svelte';
import { insideTelegram } from '../lib/telegram';
import { insideVK } from '../lib/vk';
import { t } from '../lib/i18n/index.svelte';
const canClose = insideTelegram() || insideVK();
</script>
<div class="deleted">
<div class="card">
<h1>{t('deleted.title')}</h1>
<p class="msg">{t('deleted.body')}</p>
{#if canClose}
<button class="close" onclick={closeDeletedApp}>{t('common.close')}</button>
{/if}
</div>
</div>
<style>
.deleted {
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-muted);
}
.close {
margin-top: 1.5rem;
padding: 10px 20px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
</style>
+74 -1
View File
@@ -1,7 +1,15 @@
<script lang="ts">
import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte';
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
import {
app,
applyLinkResult,
confirmDeleteAccount,
handleError,
logout,
requestDeleteAccount,
showToast,
} from '../lib/app.svelte';
import { GatewayError } from '../lib/client';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
@@ -43,6 +51,10 @@
let changingEmail = $state(false);
// A pending unlink awaiting the confirmation dialog (email is never unlinked).
let confirmUnlink = $state<null | { kind: 'telegram' | 'vk'; label: string }>(null);
// The account-deletion step-up dialog: null when closed, else the required proof method.
let deleting = $state<null | { method: 'email' | 'phrase' }>(null);
let deleteCode = $state('');
let deletePhrase = $state('');
const telegramLinkable = loginWidgetAvailable();
function defaultTz(): string {
@@ -235,6 +247,29 @@
handleError(e);
}
}
// startDelete opens the deletion dialog, asking the backend which step-up the account uses
// (a mailed code for an email account, or a typed phrase for a platform-only one).
async function startDelete() {
try {
const method = await requestDeleteAccount();
deleteCode = '';
deletePhrase = '';
deleting = { method };
} catch (e) {
handleError(e);
}
}
async function doDelete() {
if (!deleting) return;
try {
// On success the app swaps to the terminal "account deleted" screen (app.accountDeleted).
await confirmDeleteAccount(deleteCode.trim(), deletePhrase.trim());
} catch (e) {
handleError(e);
}
}
</script>
<div class="page">
@@ -381,6 +416,10 @@
{/if}
</section>
{#if !p.isGuest}
<button class="deletebtn" onclick={startDelete} disabled={!connection.online}>{t('profile.deleteAccount')}</button>
{/if}
<!-- Logout is hidden for now but kept wired — drop `hidden` to re-enable
once its entry point is decided; logout() also still runs on an invalid session. -->
<button class="logout" hidden onclick={() => logout()}>{t('login.title')} / logout</button>
@@ -409,6 +448,28 @@
</Modal>
{/if}
{#if deleting}
{@const d = deleting}
<Modal title={t('profile.deleteTitle')} onclose={() => (deleting = null)}>
<p class="warn">{t('profile.deleteWarn')}</p>
{#if d.method === 'email'}
<p class="muted">{t('profile.deleteEmailPrompt')}</p>
<div class="addrow">
<input class="codein" bind:value={deleteCode} placeholder={t('profile.emailCode')} inputmode="numeric" maxlength="6" />
</div>
{:else}
<p class="muted">{t('profile.deletePhrasePrompt')}</p>
<div class="addrow">
<input bind:value={deletePhrase} placeholder={t('profile.deletePhrasePlaceholder')} autocapitalize="characters" />
</div>
{/if}
<div class="addrow end">
<button class="ghost" onclick={() => (deleting = null)}>{t('common.cancel')}</button>
<button class="btn danger" onclick={doDelete} disabled={!connection.online}>{t('profile.deleteConfirm')}</button>
</div>
</Modal>
{/if}
<style>
.page {
padding: var(--pad);
@@ -614,6 +675,18 @@
.ghost:disabled {
opacity: 0.5;
}
.deletebtn {
margin-top: 8px;
align-self: flex-start;
padding: 8px 14px;
border: 1px solid var(--danger, #c0392b);
background: transparent;
color: var(--danger, #c0392b);
border-radius: var(--radius-sm);
}
.deletebtn:disabled {
opacity: 0.5;
}
.logout {
margin-top: 8px;
align-self: flex-start;