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