feat(profile): sign-in methods matrix — email add/change, provider link/unlink

Profile shows the account's sign-in methods for guests and durable accounts alike:
add or change email, link Telegram on the web (login widget), and unlink a linked
provider. Email is never unlinked (it is changed); unlink is offered only when
another identity remains, and a change to an address owned by another account shows
the non-disclosing 'check the address or contact support'. Add-VK-on-web stays
deferred (no VK OAuth).

Wires linkUnlink / changeEmailRequest / changeEmailConfirm through the client,
transport, mock and codec (encodeLinkUnlink + LinkResult 'unlinked'/'changed'
statuses); codec wire tests; ru/en i18n.
This commit is contained in:
Ilia Denisov
2026-07-03 09:57:05 +02:00
parent b918217497
commit e4fc7f033d
9 changed files with 269 additions and 24 deletions
+186 -19
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte';
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
import { GatewayError } from '../lib/client';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
@@ -38,6 +39,10 @@
// dialog confirms it. tgData holds the Telegram widget payload for the merge step.
let pendingMerge = $state<null | { kind: 'email' | 'telegram'; name: string; games: number; friends: number }>(null);
let tgData = '';
// The change-email sub-form is open (a new address is being entered/confirmed).
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);
const telegramLinkable = loginWidgetAvailable();
function defaultTz(): string {
@@ -73,6 +78,14 @@
const awayOk = $derived(awayDurationOk(awayStart, awayEnd));
const formValid = $derived(nameOk && awayOk && variantPrefs.length >= 1);
const emailOk = $derived(validEmail(emailInput));
// Count the account's confirmed sign-in methods, to hide an Unlink that would remove the
// last identity (the backend also refuses it — this is a UX guard, not the enforcement).
const linkedCount = $derived(
app.profile
? (app.profile.email ? 1 : 0) + (app.profile.telegramLinked ? 1 : 0) + (app.profile.vkLinked ? 1 : 0)
: 0,
);
const canUnlink = $derived(linkedCount >= 2);
// toggleVariant flips a variant in the preference set, refusing to remove the last one —
// the player must allow themselves at least one variant.
@@ -172,6 +185,56 @@
handleError(e);
}
}
// startChangeEmail opens the change-email sub-form on a clean slate (a new address then a
// code), independent of any half-finished add-email attempt.
function startChangeEmail() {
changingEmail = true;
emailSent = false;
emailInput = '';
codeInput = '';
}
async function requestChangeEmail() {
if (!emailOk) return;
try {
await gateway.changeEmailRequest(emailInput.trim());
emailSent = true;
showToast(t('profile.emailSent', { email: emailInput.trim() }));
} catch (e) {
handleError(e);
}
}
async function confirmChangeEmail() {
try {
const r = await gateway.changeEmailConfirm(emailInput.trim(), codeInput.trim());
await applyLinkResult(r);
populate();
changingEmail = false;
resetEmail();
showToast(t('profile.emailChanged'));
} catch (e) {
// A new address confirmed by another account is refused without disclosing it.
if (e instanceof GatewayError && e.code === 'email_taken') {
showToast(t('profile.emailChangeTaken'), 'error');
return;
}
handleError(e);
}
}
async function unlink(kind: 'telegram' | 'vk') {
confirmUnlink = null;
try {
const r = await gateway.linkUnlink(kind);
await applyLinkResult(r);
populate();
showToast(t('profile.unlinked'));
} catch (e) {
handleError(e);
}
}
</script>
<div class="page">
@@ -244,13 +307,41 @@
</form>
{/if}
<!-- The guest's email upgrade path: bind an email to register / sign in (a returning
address triggers the merge dialog below). Guest-only for now; provider linking
(Add VK / Add Telegram / Unlink) and the non-guest matrix come next, together
with the skipped linking specs in e2e/social.spec.ts. -->
<section class="emailbox" hidden={!p.isGuest}>
<h3>{t('profile.linkAccount')}</h3>
{#if !emailSent}
<!-- Sign-in methods: bind/change an email and link/unlink providers. A returning email
(add) triggers the merge dialog below. On the web an account can add Telegram via the
login widget; adding VK on the web is deferred (no VK OAuth) and inside a Mini App the
host provider is already linked. Email is never unlinked — it is changed. Unlink is
offered only when another identity remains (the backend also refuses the last one). -->
<section class="accounts">
<h3>{t('profile.accountsTitle')}</h3>
{#if p.email}
<div class="acctrow">
<span class="prov"><span class="ic"></span>{p.email}</span>
{#if !changingEmail}
<button class="ghost" onclick={startChangeEmail} disabled={!connection.online}>{t('profile.changeEmail')}</button>
{/if}
</div>
{#if changingEmail}
{#if !emailSent}
<div class="addrow">
<input
class:invalid={emailInput.length > 0 && !emailOk}
bind:value={emailInput}
placeholder={t('profile.newEmailPlaceholder')}
type="email"
/>
<button class="ghost" onclick={requestChangeEmail} disabled={!emailOk || !connection.online}>{t('login.sendCode')}</button>
</div>
{:else}
<div class="addrow">
<input class="codein" bind:value={codeInput} placeholder={t('profile.emailCode')} inputmode="numeric" maxlength="6" />
<button class="btn" onclick={confirmChangeEmail} disabled={!connection.online}>{t('common.ok')}</button>
</div>
{/if}
<button class="linklike" onclick={() => { changingEmail = false; resetEmail(); }}>{t('common.cancel')}</button>
{/if}
{:else if !emailSent}
<div class="addrow">
<input
class:invalid={emailInput.length > 0 && !emailOk}
@@ -262,19 +353,31 @@
</div>
{:else}
<div class="addrow">
<input
class="codein"
bind:value={codeInput}
placeholder={t('profile.emailCode')}
inputmode="numeric"
maxlength="6"
/>
<input class="codein" bind:value={codeInput} placeholder={t('profile.emailCode')} inputmode="numeric" maxlength="6" />
<button class="btn" onclick={confirmEmail} disabled={!connection.online}>{t('common.ok')}</button>
</div>
{/if}
{#if telegramLinkable}
<!-- Provider linking lands with the non-guest matrix (PR2); kept wired but hidden. -->
<button class="ghost tg" hidden onclick={linkTelegram} disabled={!connection.online}>{t('profile.linkTelegram')}</button>
{#if p.telegramLinked}
<div class="acctrow">
<span class="prov"><img src="telegram-logo.svg" alt="" width="18" height="18" />Telegram</span>
{#if canUnlink}
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'telegram', label: 'Telegram' })} disabled={!connection.online}>{t('profile.unlink')}</button>
{/if}
</div>
{:else if telegramLinkable}
<button class="ghost tg" onclick={linkTelegram} disabled={!connection.online}>
<img src="telegram-logo.svg" alt="" width="18" height="18" />{t('profile.linkTelegram')}
</button>
{/if}
{#if p.vkLinked}
<div class="acctrow">
<span class="prov"><img src="vk-logo.svg" alt="" width="18" height="18" />VK</span>
{#if canUnlink}
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
{/if}
</div>
{/if}
</section>
@@ -295,6 +398,17 @@
</Modal>
{/if}
{#if confirmUnlink}
{@const cu = confirmUnlink}
<Modal title={t('profile.unlinkTitle')} onclose={() => (confirmUnlink = null)}>
<p>{t('profile.unlinkBody', { provider: cu.label })}</p>
<div class="addrow end">
<button class="ghost" onclick={() => (confirmUnlink = null)}>{t('common.cancel')}</button>
<button class="btn danger" onclick={() => unlink(cu.kind)} disabled={!connection.online}>{t('profile.unlink')}</button>
</div>
</Modal>
{/if}
<style>
.page {
padding: var(--pad);
@@ -407,11 +521,64 @@
.btn:disabled {
opacity: 0.5;
}
.emailbox h3 {
margin: 0 0 8px;
.accounts {
display: flex;
flex-direction: column;
gap: 10px;
}
.accounts h3 {
margin: 0 0 2px;
font-size: 0.95rem;
color: var(--text-muted);
}
.acctrow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
}
.prov {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.prov .ic {
width: 18px;
text-align: center;
}
.tg {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
}
.ghost.danger {
border-color: var(--danger, #c0392b);
color: var(--danger, #c0392b);
}
.btn.danger {
background: var(--danger, #c0392b);
border-color: var(--danger, #c0392b);
color: #fff;
}
.linklike {
align-self: flex-start;
background: none;
border: none;
padding: 2px 0;
color: var(--text-muted);
text-decoration: underline;
font-size: 0.85rem;
cursor: pointer;
}
.addrow {
display: flex;
gap: 8px;