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:
@@ -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();
|
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 }) => {
|
test('chat: one message per turn — the field shows, then a caption replaces it after sending', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn
|
await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
import CommsHub from './game/CommsHub.svelte';
|
import CommsHub from './game/CommsHub.svelte';
|
||||||
import Feedback from './screens/Feedback.svelte';
|
import Feedback from './screens/Feedback.svelte';
|
||||||
import Blocked from './screens/Blocked.svelte';
|
import Blocked from './screens/Blocked.svelte';
|
||||||
|
import AccountDeleted from './screens/AccountDeleted.svelte';
|
||||||
import BootError from './screens/BootError.svelte';
|
import BootError from './screens/BootError.svelte';
|
||||||
import TelegramLaunchError from './screens/TelegramLaunchError.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):
|
<!-- 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. -->
|
show the retry screen instead of falling back to the web login. -->
|
||||||
<BootError />
|
<BootError />
|
||||||
|
{:else if app.accountDeleted}
|
||||||
|
<AccountDeleted />
|
||||||
{:else if app.blocked}
|
{:else if app.blocked}
|
||||||
<Blocked />
|
<Blocked />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { startLocalEvalMetrics } from './localeval-metrics';
|
|||||||
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme';
|
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme';
|
||||||
import {
|
import {
|
||||||
insideTelegram,
|
insideTelegram,
|
||||||
|
telegramClose,
|
||||||
collectTelegramDiag,
|
collectTelegramDiag,
|
||||||
type TelegramDiag,
|
type TelegramDiag,
|
||||||
onTelegramPath,
|
onTelegramPath,
|
||||||
@@ -32,7 +33,7 @@ import {
|
|||||||
telegramCloudGet,
|
telegramCloudGet,
|
||||||
telegramCloudSet,
|
telegramCloudSet,
|
||||||
} from './telegram';
|
} 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 { haptic } from './haptics';
|
||||||
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||||
import { parseStartParam } from './deeplink';
|
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 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. */
|
* the terminal blocked screen and all push/poll is stopped. */
|
||||||
blocked: BlockStatus | null;
|
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;
|
toast: Toast | null;
|
||||||
lastEvent: PushEvent | null;
|
lastEvent: PushEvent | null;
|
||||||
theme: ThemePref;
|
theme: ThemePref;
|
||||||
@@ -140,6 +144,7 @@ export const app = $state<{
|
|||||||
session: null,
|
session: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
blocked: null,
|
blocked: null,
|
||||||
|
accountDeleted: false,
|
||||||
toast: null,
|
toast: null,
|
||||||
lastEvent: null,
|
lastEvent: null,
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
@@ -544,6 +549,39 @@ export async function applyLinkResult(r: LinkResult): Promise<void> {
|
|||||||
void persistLanguageToServer(app.locale);
|
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
|
* 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.
|
* 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. */
|
* refused without disclosure. */
|
||||||
changeEmailRequest(email: string): Promise<void>;
|
changeEmailRequest(email: string): Promise<void>;
|
||||||
changeEmailConfirm(email: string, code: string): Promise<LinkResult>;
|
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 ---
|
// --- live stream ---
|
||||||
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
|
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
encodeEnqueue,
|
encodeEnqueue,
|
||||||
encodeExchange,
|
encodeExchange,
|
||||||
encodeExportUrlRequest,
|
encodeExportUrlRequest,
|
||||||
|
encodeAccountDeleteConfirm,
|
||||||
|
decodeDeleteRequestResult,
|
||||||
encodeGuestLogin,
|
encodeGuestLogin,
|
||||||
encodeLinkUnlink,
|
encodeLinkUnlink,
|
||||||
encodeStateRequest,
|
encodeStateRequest,
|
||||||
@@ -464,6 +466,21 @@ describe('codec', () => {
|
|||||||
expect(r.kind()).toBe('telegram');
|
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', () => {
|
it('passes an unlinked / changed LinkResult status straight through', () => {
|
||||||
for (const status of ['unlinked', 'changed'] as const) {
|
for (const status of ['unlinked', 'changed'] as const) {
|
||||||
const b = new Builder(64);
|
const b = new Builder(64);
|
||||||
|
|||||||
@@ -739,6 +739,21 @@ export function encodeLinkUnlink(kind: string): Uint8Array {
|
|||||||
return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b));
|
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 {
|
export function decodeLinkResult(buf: Uint8Array): LinkResult {
|
||||||
const r = fb.LinkResult.getRootAsLinkResult(new ByteBuffer(buf));
|
const r = fb.LinkResult.getRootAsLinkResult(new ByteBuffer(buf));
|
||||||
const sess = r.session();
|
const sess = r.session();
|
||||||
|
|||||||
@@ -188,6 +188,15 @@ export const en = {
|
|||||||
'profile.unlinked': 'Account unlinked.',
|
'profile.unlinked': 'Account unlinked.',
|
||||||
'profile.unlinkTitle': 'Unlink account?',
|
'profile.unlinkTitle': 'Unlink account?',
|
||||||
'profile.unlinkBody': 'Remove {provider} as a sign-in method? You can link it again later.',
|
'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.title': 'Settings',
|
||||||
'settings.theme': 'Theme',
|
'settings.theme': 'Theme',
|
||||||
|
|||||||
@@ -188,6 +188,15 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'profile.unlinked': 'Аккаунт отвязан.',
|
'profile.unlinked': 'Аккаунт отвязан.',
|
||||||
'profile.unlinkTitle': 'Отвязать аккаунт?',
|
'profile.unlinkTitle': 'Отвязать аккаунт?',
|
||||||
'profile.unlinkBody': 'Убрать {provider} как способ входа? Позже можно привязать снова.',
|
'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.title': 'Настройки',
|
||||||
'settings.theme': 'Тема',
|
'settings.theme': 'Тема',
|
||||||
|
|||||||
@@ -687,6 +687,10 @@ export class MockGateway implements GatewayClient {
|
|||||||
this.profile.email = email;
|
this.profile.email = email;
|
||||||
return { ...emptyLinked(), status: 'changed' };
|
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> {
|
async statsGet(): Promise<Stats> {
|
||||||
return { ...this.stats };
|
return { ...this.stats };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface TelegramWebApp {
|
|||||||
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
||||||
ready?: () => void;
|
ready?: () => void;
|
||||||
expand?: () => void;
|
expand?: () => void;
|
||||||
|
close?: () => void;
|
||||||
openTelegramLink?: (url: string) => void;
|
openTelegramLink?: (url: string) => void;
|
||||||
openLink?: (url: string) => void;
|
openLink?: (url: string) => void;
|
||||||
onEvent?: (event: string, handler: () => void) => 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;
|
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
|
// 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,
|
// 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.
|
// 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) {
|
async changeEmailConfirm(email, code) {
|
||||||
return codec.decodeLinkResult(await exec('link.email.change.confirm', codec.encodeLinkEmailConfirm(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() {
|
async statsGet() {
|
||||||
return codec.decodeStats(await exec('stats.get', codec.empty()));
|
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
|
* 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
|
* 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">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Modal from '../components/Modal.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 { GatewayError } from '../lib/client';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
@@ -43,6 +51,10 @@
|
|||||||
let changingEmail = $state(false);
|
let changingEmail = $state(false);
|
||||||
// A pending unlink awaiting the confirmation dialog (email is never unlinked).
|
// A pending unlink awaiting the confirmation dialog (email is never unlinked).
|
||||||
let confirmUnlink = $state<null | { kind: 'telegram' | 'vk'; label: string }>(null);
|
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();
|
const telegramLinkable = loginWidgetAvailable();
|
||||||
|
|
||||||
function defaultTz(): string {
|
function defaultTz(): string {
|
||||||
@@ -235,6 +247,29 @@
|
|||||||
handleError(e);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
@@ -381,6 +416,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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
|
<!-- 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. -->
|
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>
|
<button class="logout" hidden onclick={() => logout()}>{t('login.title')} / logout</button>
|
||||||
@@ -409,6 +448,28 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/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>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
padding: var(--pad);
|
padding: var(--pad);
|
||||||
@@ -614,6 +675,18 @@
|
|||||||
.ghost:disabled {
|
.ghost:disabled {
|
||||||
opacity: 0.5;
|
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 {
|
.logout {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
|
|||||||
Reference in New Issue
Block a user