From cabcd94d92f0cb512af4797a075aa550e205ed5d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 13:18:37 +0200 Subject: [PATCH] feat(profile): account-deletion flow + terminal deleted screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/e2e/social.spec.ts | 14 ++++++ ui/src/App.svelte | 3 ++ ui/src/lib/app.svelte.ts | 40 ++++++++++++++- ui/src/lib/client.ts | 5 ++ ui/src/lib/codec.test.ts | 17 +++++++ ui/src/lib/codec.ts | 15 ++++++ ui/src/lib/i18n/en.ts | 9 ++++ ui/src/lib/i18n/ru.ts | 9 ++++ ui/src/lib/mock/client.ts | 4 ++ ui/src/lib/telegram.ts | 7 +++ ui/src/lib/transport.ts | 6 +++ ui/src/lib/vk.ts | 12 +++++ ui/src/screens/AccountDeleted.svelte | 53 ++++++++++++++++++++ ui/src/screens/Profile.svelte | 75 +++++++++++++++++++++++++++- 14 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 ui/src/screens/AccountDeleted.svelte diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 69be846..ac06423 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -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 diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 215f47b..a1b8824 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -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 @@ +{:else if app.accountDeleted} + {:else if app.blocked} {:else} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 2cce2a4..38593c7 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -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 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 { + 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. diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 3769da8..f6a235c 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -185,6 +185,11 @@ export interface GatewayClient { * refused without disclosure. */ changeEmailRequest(email: string): Promise; changeEmailConfirm(email: string, code: string): Promise; + /** 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; // --- live stream --- subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 377921a..82d13c6 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -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); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 6ff6739..4c34194 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -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(); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 9796b94..5aef200 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -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', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 9b5503c..9023d99 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -188,6 +188,15 @@ export const ru: Record = { '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': 'Тема', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 28649af..f0d27cc 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -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 {} async statsGet(): Promise { return { ...this.stats }; } diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index e9ad334..74f23da 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -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. diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 99bf7df..12863af 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -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())); }, diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index aa236ff..2fb04fe 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -52,6 +52,18 @@ export async function vkInit(): Promise { } } +/** + * 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 { + 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 diff --git a/ui/src/screens/AccountDeleted.svelte b/ui/src/screens/AccountDeleted.svelte new file mode 100644 index 0000000..372e71f --- /dev/null +++ b/ui/src/screens/AccountDeleted.svelte @@ -0,0 +1,53 @@ + + +
+
+

{t('deleted.title')}

+

{t('deleted.body')}

+ {#if canClose} + + {/if} +
+
+ + diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index db1b42a..3ae6d94 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -1,7 +1,15 @@
@@ -381,6 +416,10 @@ {/if} + {#if !p.isGuest} + + {/if} + @@ -409,6 +448,28 @@ {/if} +{#if deleting} + {@const d = deleting} + (deleting = null)}> +

{t('profile.deleteWarn')}

+ {#if d.method === 'email'} +

{t('profile.deleteEmailPrompt')}

+
+ +
+ {:else} +

{t('profile.deletePhrasePrompt')}

+
+ +
+ {/if} +
+ + +
+
+{/if} +