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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user