feat(account): unlink provider + change-email edges (backend + gateway)

Unlink: POST /user/link/unlink (telegram|vk) via account.RemoveIdentity, refusing
the last identity; email is never unlinked. New fbs LinkUnlinkRequest + gateway
link.unlink op, returning the refreshed profile.

Change-email: purposeChange confirm-codes (RequestChangeCode/ConfirmChange) that
atomically replace the account's confirmed email (account.replaceEmailIdentity);
a new address owned by another account is refused without disclosure, never merged.
The one-tap deeplink handles purposeChange too. Reuses the LinkEmail* fbs tables;
gateway link.email.change.{request,confirm} ops + backendclient methods; branded
ru/en change-email copy.
This commit is contained in:
Ilia Denisov
2026-07-03 09:47:42 +02:00
parent a3eb4719de
commit b918217497
11 changed files with 419 additions and 10 deletions
@@ -335,6 +335,31 @@ func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID strin
return out, err
}
// ChangeEmailRequest asks the backend to mail a confirm-code to a new address for an
// authenticated email change.
func (c *Client) ChangeEmailRequest(ctx context.Context, userID, email string) error {
return c.do(ctx, http.MethodPost, "/api/v1/user/link/email/change/request", userID, "",
map[string]string{"email": email}, nil)
}
// ChangeEmailConfirm verifies the code and atomically switches the account's email,
// returning the refreshed profile in the result.
func (c *Client) ChangeEmailConfirm(ctx context.Context, userID, email, code string) (LinkResultResp, error) {
var out LinkResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/email/change/confirm", userID, "",
map[string]string{"email": email, "code": code}, &out)
return out, err
}
// LinkUnlink detaches a platform identity (kind = "telegram" | "vk") from the caller
// and returns the refreshed profile in the result.
func (c *Client) LinkUnlink(ctx context.Context, userID, kind string) (LinkResultResp, error) {
var out LinkResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/unlink", userID, "",
map[string]string{"kind": kind}, &out)
return out, err
}
// Stats returns the caller's lifetime statistics.
func (c *Client) Stats(ctx context.Context, userID string) (StatsResp, error) {
var out StatsResp
+48 -5
View File
@@ -13,11 +13,14 @@ import (
// authenticated. The merge ops are the explicit irreversible step, gated in the UI
// after a merge_required confirm.
const (
MsgLinkEmailRequest = "link.email.request"
MsgLinkEmailConfirm = "link.email.confirm"
MsgLinkEmailMerge = "link.email.merge"
MsgLinkTelegram = "link.telegram.confirm"
MsgLinkTelegramMerge = "link.telegram.merge"
MsgLinkEmailRequest = "link.email.request"
MsgLinkEmailConfirm = "link.email.confirm"
MsgLinkEmailMerge = "link.email.merge"
MsgLinkTelegram = "link.telegram.confirm"
MsgLinkTelegramMerge = "link.telegram.merge"
MsgLinkUnlink = "link.unlink"
MsgEmailChangeRequest = "link.email.change.request"
MsgEmailChangeConfirm = "link.email.change.confirm"
)
// registerLinkOps adds the linking & merge operations. The telegram ops need the
@@ -26,6 +29,9 @@ func registerLinkOps(r *Registry, backend *backendclient.Client, tg TelegramVali
r.ops[MsgLinkEmailRequest] = Op{Handler: linkEmailRequestHandler(backend), Auth: true, Email: true}
r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend), Auth: true, Email: true}
r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend), Auth: true, Email: true}
r.ops[MsgLinkUnlink] = Op{Handler: linkUnlinkHandler(backend), Auth: true}
r.ops[MsgEmailChangeRequest] = Op{Handler: changeEmailRequestHandler(backend), Auth: true, Email: true}
r.ops[MsgEmailChangeConfirm] = Op{Handler: changeEmailConfirmHandler(backend), Auth: true, Email: true}
if tg != nil {
r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false), Auth: true}
r.ops[MsgLinkTelegramMerge] = Op{Handler: linkTelegramHandler(backend, tg, true), Auth: true}
@@ -64,6 +70,43 @@ func linkEmailMergeHandler(backend *backendclient.Client) Handler {
}
}
// changeEmailRequestHandler mails a confirm-code to a new address for an email change.
func changeEmailRequestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsLinkEmailRequest(req.Payload, 0)
if err := backend.ChangeEmailRequest(ctx, req.UserID, string(in.Email())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
// changeEmailConfirmHandler verifies the code and switches the account's email, returning
// the refreshed link result (status "changed" + the updated profile).
func changeEmailConfirmHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsLinkEmailConfirm(req.Payload, 0)
res, err := backend.ChangeEmailConfirm(ctx, req.UserID, string(in.Email()), string(in.Code()))
if err != nil {
return nil, err
}
return encodeLinkResult(res), nil
}
}
// linkUnlinkHandler detaches a platform identity (telegram|vk) from the caller and
// returns the refreshed link result (status "unlinked" + the updated profile).
func linkUnlinkHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsLinkUnlinkRequest(req.Payload, 0)
res, err := backend.LinkUnlink(ctx, req.UserID, string(in.Kind()))
if err != nil {
return nil, err
}
return encodeLinkResult(res), nil
}
}
// linkTelegramHandler validates Login Widget data via the connector and then calls
// the backend's link or merge endpoint with the trusted Telegram external id.
func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, merge bool) Handler {