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
+70 -5
View File
@@ -35,12 +35,13 @@ const (
)
// Confirmation purposes recorded on a pending confirm-code row. They select what
// verifying the code or the deeplink token does: sign in (login) or link/confirm the
// address on the current account (link). Email change and account deletion add
// further purposes in later stages.
// verifying the code or the deeplink token does: sign in (login), link/confirm the
// address on the current account (link), or replace the account's confirmed email with
// a new address (change). Account deletion adds a further purpose in a later stage.
const (
purposeLogin = "login"
purposeLink = "link"
purposeLogin = "login"
purposeLink = "link"
purposeChange = "change"
)
// Errors returned by the email confirm-code flow.
@@ -326,6 +327,26 @@ func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkCo
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
case purposeChange:
owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email)
if err != nil {
return LinkConfirmation{}, err
}
if ok && owner != pend.accountID {
// The new address is confirmed by a different account: refuse without
// disclosing it (anti-enumeration). Unlike a link, a change never merges.
return LinkConfirmation{}, ErrEmailTaken
}
if ok && owner == pend.accountID {
if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil
}
if err := s.store.replaceEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil
default:
return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose)
}
@@ -506,6 +527,50 @@ func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accoun
return nil
}
// replaceEmailIdentity consumes the confirmation, deletes the account's existing email
// identity (freeing the old address) and inserts newEmail as its confirmed email, inside
// one transaction. It backs the change-email flow. A unique-constraint violation — the
// new address was confirmed elsewhere in the meantime — surfaces as ErrEmailTaken. When
// the account holds no email identity yet the delete is a no-op, so this doubles as an
// attach.
func (s *Store) replaceEmailIdentity(ctx context.Context, confirmationID, accountID uuid.UUID, newEmail string, now time.Time) error {
identityID, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new identity id: %w", err)
}
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
upd := table.EmailConfirmations.
UPDATE(table.EmailConfirmations.ConsumedAt).
SET(postgres.TimestampzT(now)).
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("consume confirmation: %w", err)
}
del := table.Identities.DELETE().WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
)
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("delete old email identity: %w", err)
}
ins := table.Identities.INSERT(
table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
table.Identities.ExternalID, table.Identities.Confirmed,
).VALUES(identityID, accountID, KindEmail, newEmail, true)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return err
}
return nil
})
if err != nil {
if isUniqueViolation(err) {
return ErrEmailTaken
}
return fmt.Errorf("account: replace email identity: %w", err)
}
return nil
}
// confirmEmailLogin consumes the login code and marks the existing email
// identity confirmed, inside one transaction. The identity already exists (a
// login provisioned it), so this updates rather than inserts and is idempotent
+18
View File
@@ -80,6 +80,24 @@ var confirmEmailCopy = map[string]map[string]emailCopy{
FooterIgnore: "Если вы не запрашивали это письмо, просто проигнорируйте его.",
},
},
purposeChange: {
"en": {
Subject: "Confirm your new Erudit e-mail",
Preheader: "Confirm your new address",
Heading: "Confirm your new e-mail",
Intro: "Enter this code to switch your account to this address:",
CTALabel: "Confirm with one tap",
FooterIgnore: "If you didn't request this change, you can safely ignore it — your address stays the same.",
},
"ru": {
Subject: "Подтвердите новый e-mail в Эрудит",
Preheader: "Подтвердите новый адрес",
Heading: "Смена e-mail",
Intro: "Введите этот код, чтобы привязать аккаунт к новому адресу:",
CTALabel: "Подтвердить одним нажатием",
FooterIgnore: "Если вы не запрашивали смену, просто проигнорируйте письмо — адрес останется прежним.",
},
},
}
// emailBrand is the brand wordmark per locale.
+51
View File
@@ -119,6 +119,57 @@ func (s *EmailService) ConfirmLink(ctx context.Context, accountID uuid.UUID, ema
return accountID, true, nil
}
// RequestChangeCode issues and mails a confirm-code to newEmail for an authenticated
// email change on accountID, replacing any prior pending code. Like RequestLinkCode it
// never refuses up front on "taken" (anti-enumeration): possession of newEmail is the
// authorization, and a conflict with another account is revealed only at confirm — as a
// non-disclosing refusal, never a merge.
func (s *EmailService) RequestChangeCode(ctx context.Context, accountID uuid.UUID, newEmail string) error {
addr, err := normalizeEmail(newEmail)
if err != nil {
return err
}
if !s.allowSend(addr) {
return ErrTooManyRequests
}
return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID))
}
// ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the
// account's confirmed email with newEmail, freeing the old address. When newEmail is
// already confirmed by another account it refuses with ErrEmailTaken (surfaced to the
// user as a non-disclosing "check the address or contact support"), never merging; when
// the account already owns newEmail it is an idempotent no-op. It returns the usual
// confirm-code errors (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts,
// ErrCodeMismatch) and the updated account on success.
func (s *EmailService) ConfirmChange(ctx context.Context, accountID uuid.UUID, newEmail, code string) (Account, error) {
addr, err := normalizeEmail(newEmail)
if err != nil {
return Account{}, err
}
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
if err != nil {
return Account{}, err
}
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
if err != nil {
return Account{}, err
}
if ok && owner != accountID {
return Account{}, ErrEmailTaken
}
if ok && owner == accountID {
if err := s.store.consumeConfirmation(ctx, conf.id, s.now()); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, accountID)
}
if err := s.store.replaceEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, accountID)
}
// verifyPendingCode loads and checks the pending confirm-code for (accountID,
// addr), counting a wrong attempt. It returns the confirmation on success.
func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) {
+8
View File
@@ -74,6 +74,12 @@ func (s *Server) registerRoutes() {
u.POST("/link/email/merge", s.handleLinkEmailMerge)
u.POST("/link/telegram", s.handleLinkTelegram)
u.POST("/link/telegram/merge", s.handleLinkTelegramMerge)
u.POST("/link/unlink", s.handleUnlink)
// Change the account's confirmed email: mail a code to the new address, then
// atomically switch on confirm (a new address owned by another account is
// refused without disclosure, never merged).
u.POST("/link/email/change/request", s.handleChangeEmailRequest)
u.POST("/link/email/change/confirm", s.handleChangeEmailConfirm)
}
if s.games != nil {
u.GET("/games", s.handleListGames)
@@ -231,6 +237,8 @@ func statusForError(err error) (int, string) {
return http.StatusUnprocessableEntity, "illegal_play"
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
return http.StatusConflict, "email_taken"
case errors.Is(err, account.ErrLastIdentity):
return http.StatusConflict, "last_identity"
case errors.Is(err, accountmerge.ErrActiveGameConflict):
return http.StatusConflict, "merge_active_game_conflict"
case errors.Is(err, account.ErrInvalidEmail):
+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) {