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:
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Тема',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()));
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user