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
+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