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