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