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
+8
View File
@@ -177,6 +177,14 @@ export interface GatewayClient {
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
linkTelegram(data: string): Promise<LinkResult>;
linkTelegramMerge(data: string): Promise<LinkResult>;
/** 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<LinkResult>;
/** 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<void>;
changeEmailConfirm(email: string, code: string): Promise<LinkResult>;
// --- live stream ---
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
+17
View File
@@ -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');
+8
View File
@@ -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();
+9
View File
@@ -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',
+9
View File
@@ -179,6 +179,15 @@ export const ru: Record<MessageKey, string> = {
'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': 'Тема',
+18 -1
View File
@@ -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<LinkResult> {
async linkEmailMerge(email: string, _code: string): Promise<LinkResult> {
this.profile.isGuest = false;
this.profile.email = email;
return { ...emptyLinked(), status: 'merged' };
}
async linkTelegram(_data: string): Promise<LinkResult> {
this.profile.isGuest = false;
this.profile.telegramLinked = true;
return emptyLinked();
}
async linkTelegramMerge(_data: string): Promise<LinkResult> {
this.profile.isGuest = false;
this.profile.telegramLinked = true;
return { ...emptyLinked(), status: 'merged' };
}
async linkUnlink(kind: string): Promise<LinkResult> {
if (kind === 'telegram') this.profile.telegramLinked = false;
if (kind === 'vk') this.profile.vkLinked = false;
return { ...emptyLinked(), status: 'unlinked' };
}
async changeEmailRequest(_email: string): Promise<void> {}
async changeEmailConfirm(email: string, _code: string): Promise<LinkResult> {
// 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<Stats> {
return { ...this.stats };
}
+5 -4
View File
@@ -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;
+9
View File
@@ -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()));
},