feat(account): provider linking, unlink & email change (PR2) #163

Merged
developer merged 8 commits from feature/email-relay-pr2 into development 2026-07-03 09:16:04 +00:00
11 changed files with 419 additions and 10 deletions
Showing only changes of commit b918217497 - Show all commits
+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) {
@@ -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 {
+6
View File
@@ -501,6 +501,12 @@ table LinkTelegramRequest {
data:string;
}
// LinkUnlinkRequest detaches a platform identity (kind = "telegram" | "vk") from the
// caller's account; email is never unlinked (it is changed).
table LinkUnlinkRequest {
kind:string;
}
// LinkResult is the unified result of a confirm or merge step. status is "linked"
// (bound to the caller), "merge_required" (the identity belongs to another account —
// the secondary_* fields summarise it for the irreversible confirmation), or
+60
View File
@@ -0,0 +1,60 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type LinkUnlinkRequest struct {
_tab flatbuffers.Table
}
func GetRootAsLinkUnlinkRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkUnlinkRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &LinkUnlinkRequest{}
x.Init(buf, n+offset)
return x
}
func FinishLinkUnlinkRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsLinkUnlinkRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkUnlinkRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &LinkUnlinkRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedLinkUnlinkRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *LinkUnlinkRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *LinkUnlinkRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *LinkUnlinkRequest) Kind() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func LinkUnlinkRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func LinkUnlinkRequestAddKind(builder *flatbuffers.Builder, kind flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(kind), 0)
}
func LinkUnlinkRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+1
View File
@@ -51,6 +51,7 @@ export { LinkEmailConfirm } from './scrabblefb/link-email-confirm.js';
export { LinkEmailRequest } from './scrabblefb/link-email-request.js';
export { LinkResult } from './scrabblefb/link-result.js';
export { LinkTelegramRequest } from './scrabblefb/link-telegram-request.js';
export { LinkUnlinkRequest } from './scrabblefb/link-unlink-request.js';
export { MatchFoundEvent } from './scrabblefb/match-found-event.js';
export { MatchResult } from './scrabblefb/match-result.js';
export { MoveRecord } from './scrabblefb/move-record.js';
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class LinkUnlinkRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):LinkUnlinkRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsLinkUnlinkRequest(bb:flatbuffers.ByteBuffer, obj?:LinkUnlinkRequest):LinkUnlinkRequest {
return (obj || new LinkUnlinkRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsLinkUnlinkRequest(bb:flatbuffers.ByteBuffer, obj?:LinkUnlinkRequest):LinkUnlinkRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new LinkUnlinkRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
kind():string|null
kind(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
kind(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startLinkUnlinkRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addKind(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, kindOffset, 0);
}
static endLinkUnlinkRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createLinkUnlinkRequest(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset):flatbuffers.Offset {
LinkUnlinkRequest.startLinkUnlinkRequest(builder);
LinkUnlinkRequest.addKind(builder, kindOffset);
return LinkUnlinkRequest.endLinkUnlinkRequest(builder);
}
}