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
+84
View File
@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/link"
)
@@ -66,6 +67,89 @@ func (s *Server) handleLinkEmailRequest(c *gin.Context) {
c.JSON(http.StatusOK, okResponse{OK: true})
}
// unlinkBody carries the provider kind to detach.
type unlinkBody struct {
Kind string `json:"kind"`
}
// handleUnlink detaches a platform identity (telegram or vk) from the caller's
// account, refusing to remove the last identity (ErrLastIdentity). Email is never
// unlinked — it is replaced through the change-email flow — so an email kind is
// rejected. It returns the refreshed profile so the client updates its controls.
func (s *Server) handleUnlink(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req unlinkBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
if req.Kind != account.KindTelegram && req.Kind != account.KindVK {
abortBadRequest(c, "only telegram or vk can be unlinked")
return
}
ctx := c.Request.Context()
if err := s.accounts.RemoveIdentity(ctx, uid, req.Kind); err != nil {
s.abortErr(c, err)
return
}
acc, err := s.accounts.GetByID(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
r := s.profileResponse(ctx, acc)
c.JSON(http.StatusOK, linkResultResponse{Status: "unlinked", Profile: &r})
}
// handleChangeEmailRequest mails a confirm-code to a new address for an authenticated
// email change. Like the link request it never signals "taken" up front — a conflict is
// only revealed (non-disclosingly) at confirm.
func (s *Server) handleChangeEmailRequest(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkEmailRequestBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
if err := s.emails.RequestChangeCode(c.Request.Context(), uid, req.Email); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleChangeEmailConfirm verifies the code and atomically switches the account to the
// new address, returning the refreshed profile. A new address confirmed by another
// account is refused (ErrEmailTaken → the non-disclosing message), never merged.
func (s *Server) handleChangeEmailConfirm(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkEmailConfirmBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
ctx := c.Request.Context()
acc, err := s.emails.ConfirmChange(ctx, uid, req.Email, req.Code)
if err != nil {
s.abortErr(c, err)
return
}
r := s.profileResponse(ctx, acc)
c.JSON(http.StatusOK, linkResultResponse{Status: "changed", Profile: &r})
}
// handleLinkEmailConfirm verifies the code and binds a free email or reports a
// required merge.
func (s *Server) handleLinkEmailConfirm(c *gin.Context) {