diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index b1767ff..3769da8 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -177,6 +177,14 @@ export interface GatewayClient { linkEmailMerge(email: string, code: string): Promise; linkTelegram(data: string): Promise; linkTelegramMerge(data: string): Promise; + /** Detach a platform identity (kind 'telegram' | 'vk') from the account; email is never + * unlinked. Returns the refreshed link result (status 'unlinked'). */ + linkUnlink(kind: string): Promise; + /** Change the account's confirmed email: mail a code to the new address, then confirm + * to atomically switch (status 'changed'). A new address owned by another account is + * refused without disclosure. */ + changeEmailRequest(email: string): Promise; + changeEmailConfirm(email: string, code: string): Promise; // --- live stream --- subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 4e1b1ab..377921a 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -28,6 +28,7 @@ import { encodeExchange, encodeExportUrlRequest, encodeGuestLogin, + encodeLinkUnlink, encodeStateRequest, encodeSubmitPlay, encodeTarget, @@ -458,6 +459,22 @@ describe('codec', () => { }); }); + it('encodes an unlink request carrying the provider kind', () => { + const r = fb.LinkUnlinkRequest.getRootAsLinkUnlinkRequest(new ByteBuffer(encodeLinkUnlink('telegram'))); + expect(r.kind()).toBe('telegram'); + }); + + it('passes an unlinked / changed LinkResult status straight through', () => { + for (const status of ['unlinked', 'changed'] as const) { + const b = new Builder(64); + const s = b.createString(status); + fb.LinkResult.startLinkResult(b); + fb.LinkResult.addStatus(b, s); + b.finish(fb.LinkResult.endLinkResult(b)); + expect(decodeLinkResult(b.asUint8Array()).status).toBe(status); + } + }); + it('decodes a merged LinkResult carrying a switched session', () => { const b = new Builder(128); const token = b.createString('tok-9'); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index a7e745f..6ff6739 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -731,6 +731,14 @@ export function encodeLinkTelegram(data: string): Uint8Array { return finish(b, fb.LinkTelegramRequest.endLinkTelegramRequest(b)); } +export function encodeLinkUnlink(kind: string): Uint8Array { + const b = new Builder(64); + const k = b.createString(kind); + fb.LinkUnlinkRequest.startLinkUnlinkRequest(b); + fb.LinkUnlinkRequest.addKind(b, k); + return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b)); +} + export function decodeLinkResult(buf: Uint8Array): LinkResult { const r = fb.LinkResult.getRootAsLinkResult(new ByteBuffer(buf)); const sess = r.session(); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 0223ffc..9796b94 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -179,6 +179,15 @@ export const en = { 'profile.mergeBody': 'This identity already belongs to “{name}” ({games} games, {friends} friends).', 'profile.mergeIrreversible': 'Merging combines both accounts into this one and cannot be undone.', 'profile.mergeConfirm': 'Merge', + 'profile.accountsTitle': 'Sign-in methods', + 'profile.changeEmail': 'Change', + 'profile.newEmailPlaceholder': 'New email address', + 'profile.emailChanged': 'Email updated.', + 'profile.emailChangeTaken': 'Check the address or contact support.', + 'profile.unlink': 'Unlink', + 'profile.unlinked': 'Account unlinked.', + 'profile.unlinkTitle': 'Unlink account?', + 'profile.unlinkBody': 'Remove {provider} as a sign-in method? You can link it again later.', 'settings.title': 'Settings', 'settings.theme': 'Theme', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 068a61f..9b5503c 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -179,6 +179,15 @@ export const ru: Record = { 'profile.mergeBody': 'Эта личность уже принадлежит «{name}» (игр: {games}, друзей: {friends}).', 'profile.mergeIrreversible': 'Объединение сольёт оба аккаунта в этот и необратимо.', 'profile.mergeConfirm': 'Объединить', + 'profile.accountsTitle': 'Способы входа', + 'profile.changeEmail': 'Изменить', + 'profile.newEmailPlaceholder': 'Новый адрес e-mail', + 'profile.emailChanged': 'E-mail изменён.', + 'profile.emailChangeTaken': 'Проверьте правильность e-mail или обратитесь в поддержку.', + 'profile.unlink': 'Отвязать', + 'profile.unlinked': 'Аккаунт отвязан.', + 'profile.unlinkTitle': 'Отвязать аккаунт?', + 'profile.unlinkBody': 'Убрать {provider} как способ входа? Позже можно привязать снова.', 'settings.title': 'Настройки', 'settings.theme': 'Тема', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 2aca5d8..28649af 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -656,20 +656,37 @@ export class MockGateway implements GatewayClient { }; } this.profile.isGuest = false; + this.profile.email = email; return emptyLinked(); } - async linkEmailMerge(_email: string, _code: string): Promise { + async linkEmailMerge(email: string, _code: string): Promise { this.profile.isGuest = false; + this.profile.email = email; return { ...emptyLinked(), status: 'merged' }; } async linkTelegram(_data: string): Promise { this.profile.isGuest = false; + this.profile.telegramLinked = true; return emptyLinked(); } async linkTelegramMerge(_data: string): Promise { this.profile.isGuest = false; + this.profile.telegramLinked = true; return { ...emptyLinked(), status: 'merged' }; } + async linkUnlink(kind: string): Promise { + if (kind === 'telegram') this.profile.telegramLinked = false; + if (kind === 'vk') this.profile.vkLinked = false; + return { ...emptyLinked(), status: 'unlinked' }; + } + async changeEmailRequest(_email: string): Promise {} + async changeEmailConfirm(email: string, _code: string): Promise { + // An address containing "taken" stands in for one confirmed by another account, so the + // mock can drive the non-disclosing refusal (never a merge). + if (email.includes('taken')) throw new GatewayError('email_taken'); + this.profile.email = email; + return { ...emptyLinked(), status: 'changed' }; + } async statsGet(): Promise { return { ...this.stats }; } diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index b9bbff4..678e7c7 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -346,13 +346,14 @@ export interface Session { displayName: string; } -// LinkResult is the outcome of an account link/merge step. status is +// LinkResult is the outcome of an account link/merge/unlink/change step. status is // 'linked' (bound to the current account), 'merge_required' (the identity belongs to // another account — the secondary* fields summarise it for the irreversible -// confirmation) or 'merged'. session is set only when the active account switched -// (a guest initiator whose durable counterpart won); the client adopts it. +// confirmation), 'merged', 'unlinked' (a provider detached) or 'changed' (the email was +// switched). session is set only when the active account switched (a guest initiator +// whose durable counterpart won); the client adopts it. export interface LinkResult { - status: 'linked' | 'merge_required' | 'merged'; + status: 'linked' | 'merge_required' | 'merged' | 'unlinked' | 'changed'; secondaryUserId: string; secondaryDisplayName: string; secondaryGames: number; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 62b07a6..99bf7df 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -268,6 +268,15 @@ export function createTransport(baseUrl: string): GatewayClient { async linkTelegramMerge(data) { return codec.decodeLinkResult(await exec('link.telegram.merge', codec.encodeLinkTelegram(data))); }, + async linkUnlink(kind) { + return codec.decodeLinkResult(await exec('link.unlink', codec.encodeLinkUnlink(kind))); + }, + async changeEmailRequest(email) { + await exec('link.email.change.request', codec.encodeLinkEmailRequest(email)); + }, + async changeEmailConfirm(email, code) { + return codec.decodeLinkResult(await exec('link.email.change.confirm', codec.encodeLinkEmailConfirm(email, code))); + }, async statsGet() { return codec.decodeStats(await exec('stats.get', codec.empty())); }, diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index 8590b3a..274ec8e 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -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); 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); 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); + } + }
@@ -244,13 +307,41 @@ {/if} - -