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
33 changed files with 1091 additions and 79 deletions
+68 -3
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"
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.
@@ -16,6 +16,8 @@ func TestRenderConfirmationEmail(t *testing.T) {
{"login en", purposeLogin, "en", "sign-in"},
{"link ru", purposeLink, "ru", "подтвержд"},
{"link en", purposeLink, "en", "confirmation"},
{"change ru", purposeChange, "ru", "новый"},
{"change en", purposeChange, "en", "new"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
+71 -12
View File
@@ -21,25 +21,25 @@ var ErrIdentityTaken = errors.New("account: identity already linked to another a
// none, making it unreachable after logout. The admin email-erase refuses it.
var ErrLastIdentity = errors.New("account: cannot remove the last identity")
// RemoveEmailIdentity deletes the account's email identity and any pending confirmations
// for it, freeing the address for reuse. It refuses when the email is the account's only
// identity (ErrLastIdentity) — that would leave the account unreachable — and returns
// ErrNotFound when the account has no email identity. It backs the admin console's
// "erase email" action.
func (s *Store) RemoveEmailIdentity(ctx context.Context, accountID uuid.UUID) error {
// RemoveIdentity deletes the account's identity of the given kind (and, for an email,
// any pending confirmations for it), freeing it for reuse. It refuses when that is the
// account's only identity (ErrLastIdentity) — which would leave the account
// unreachable — and returns ErrNotFound when the account has no identity of that kind.
// It backs the profile Unlink control and the admin "erase email" action.
func (s *Store) RemoveIdentity(ctx context.Context, accountID uuid.UUID, kind string) error {
ids, err := s.Identities(ctx, accountID)
if err != nil {
return err
}
hasEmail, others := false, 0
hasKind, others := false, 0
for _, id := range ids {
if id.Kind == KindEmail {
hasEmail = true
if id.Kind == kind {
hasKind = true
} else {
others++
}
}
if !hasEmail {
if !hasKind {
return ErrNotFound
}
if others == 0 {
@@ -48,21 +48,29 @@ func (s *Store) RemoveEmailIdentity(ctx context.Context, accountID uuid.UUID) er
return withTx(ctx, s.db, func(tx *sql.Tx) error {
delID := table.Identities.DELETE().WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
AND(table.Identities.Kind.EQ(postgres.String(kind))),
)
if _, err := delID.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("account: delete email identity %s: %w", accountID, err)
return fmt.Errorf("account: delete %s identity %s: %w", kind, accountID, err)
}
if kind == KindEmail {
delConf := table.EmailConfirmations.DELETE().WHERE(
table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)),
)
if _, err := delConf.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("account: delete email confirmations %s: %w", accountID, err)
}
}
return nil
})
}
// RemoveEmailIdentity erases the account's email identity. It backs the admin console's
// "erase email" action; the user-facing profile never unlinks email (it is changed).
func (s *Store) RemoveEmailIdentity(ctx context.Context, accountID uuid.UUID) error {
return s.RemoveIdentity(ctx, accountID, KindEmail)
}
// RequestLinkCode issues and mails a confirm-code for email to accountID,
// replacing any prior pending code. Unlike RequestCode it never refuses up front
// (taken or already-confirmed): possession of the address is the authorization for
@@ -111,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) {
@@ -0,0 +1,174 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// emailOf returns the external id of the account's email identity, or "" when it has none.
func emailOf(t *testing.T, store *account.Store, id uuid.UUID) string {
t.Helper()
ids, err := store.Identities(context.Background(), id)
if err != nil {
t.Fatalf("identities %s: %v", id, err)
}
for _, i := range ids {
if i.Kind == account.KindEmail {
return i.ExternalID
}
}
return ""
}
// TestUnlinkProviderKeepsOthers detaches one provider from a multi-identity account and
// refuses to remove the last remaining identity.
func TestUnlinkProviderKeepsOthers(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
email := "unlink-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil {
t.Fatalf("attach email: %v", err)
}
// Removing a kind the account does not hold reports not-found.
if err := store.RemoveIdentity(ctx, acc.ID, account.KindVK); !errors.Is(err, account.ErrNotFound) {
t.Fatalf("remove absent vk = %v, want ErrNotFound", err)
}
// Detach Telegram: the email identity remains.
if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil {
t.Fatalf("remove telegram: %v", err)
}
ids, err := store.Identities(ctx, acc.ID)
if err != nil {
t.Fatalf("identities: %v", err)
}
if len(ids) != 1 || ids[0].Kind != account.KindEmail {
t.Fatalf("identities after unlink = %+v, want only email", ids)
}
// The email is now the last identity, so unlinking it is refused.
if err := store.RemoveIdentity(ctx, acc.ID, account.KindEmail); !errors.Is(err, account.ErrLastIdentity) {
t.Fatalf("remove last email = %v, want ErrLastIdentity", err)
}
}
// TestChangeEmailReplaces switches an account's confirmed email to a free address,
// freeing the old one.
func TestChangeEmailReplaces(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
oldAddr := "old-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, oldAddr, true); err != nil {
t.Fatalf("attach old email: %v", err)
}
newAddr := "new-" + uuid.NewString() + "@example.com"
if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil {
t.Fatalf("request change: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := svc.ConfirmChange(ctx, acc.ID, newAddr, code); err != nil {
t.Fatalf("confirm change: %v", err)
}
if got := emailOf(t, store, acc.ID); got != newAddr {
t.Fatalf("email after change = %q, want %q", got, newAddr)
}
if !identityConfirmed(t, account.KindEmail, newAddr) {
t.Error("the new email identity must be confirmed")
}
// The old address is freed: another account can now claim it.
other, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision other: %v", err)
}
if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, oldAddr, true); err != nil {
t.Fatalf("old address should be free after change, got: %v", err)
}
}
// TestChangeEmailRefusesTaken refuses (without merging) a new address already confirmed by
// another account, leaving the caller's email untouched.
func TestChangeEmailRefusesTaken(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision caller: %v", err)
}
mine := "mine-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, mine, true); err != nil {
t.Fatalf("attach caller email: %v", err)
}
other, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision other: %v", err)
}
taken := "taken-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, taken, true); err != nil {
t.Fatalf("attach other email: %v", err)
}
if err := svc.RequestChangeCode(ctx, acc.ID, taken); err != nil {
t.Fatalf("request change: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := svc.ConfirmChange(ctx, acc.ID, taken, code); !errors.Is(err, account.ErrEmailTaken) {
t.Fatalf("confirm change to taken = %v, want ErrEmailTaken", err)
}
if got := emailOf(t, store, acc.ID); got != mine {
t.Errorf("caller email after refused change = %q, want unchanged %q", got, mine)
}
}
// TestChangeEmailViaDeeplink switches the email through the one-tap confirm token.
func TestChangeEmailViaDeeplink(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "old-"+uuid.NewString()+"@example.com", true); err != nil {
t.Fatalf("attach old email: %v", err)
}
newAddr := "dl-" + uuid.NewString() + "@example.com"
if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil {
t.Fatalf("request change: %v", err)
}
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
if err != nil {
t.Fatalf("confirm by token: %v", err)
}
if res.IsLogin() || res.NeedsMerge || res.Account != acc.ID {
t.Fatalf("deeplink change result = %+v, want a plain change for %s", res, acc.ID)
}
if got := emailOf(t, store, acc.ID); got != newAddr {
t.Fatalf("email after deeplink change = %q, want %q", got, newAddr)
}
}
+25
View File
@@ -45,9 +45,34 @@ type bannerTimingsDTO struct {
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
r := profileResponseFor(acc)
r.Banner = s.bannerFor(ctx, acc)
s.fillLinkedIdentities(ctx, &r, acc.ID)
return r
}
// fillLinkedIdentities sets the profile's confirmed email address and platform-linked
// flags from the account's identities, so the client offers the right link / unlink /
// change-email controls. A read failure leaves them zero (no controls), logged as a
// warning so the profile response still succeeds.
func (s *Server) fillLinkedIdentities(ctx context.Context, r *profileResponse, accountID uuid.UUID) {
ids, err := s.accounts.Identities(ctx, accountID)
if err != nil {
s.log.Warn("profile: identities read failed", zap.String("account", accountID.String()), zap.Error(err))
return
}
for _, id := range ids {
switch id.Kind {
case account.KindEmail:
if id.Confirmed {
r.Email = id.ExternalID
}
case account.KindTelegram:
r.TelegramLinked = true
case account.KindVK:
r.VkLinked = true
}
}
}
// bannerFor builds the advertising-banner block for the account's profile, or
// nil when the ads service is not configured or the viewer is not eligible to
// see a banner. The message language follows the account's bot (service)
+7
View File
@@ -56,6 +56,13 @@ type profileResponse struct {
// see the banner (a free account with an empty hint wallet and without the
// no_banner role), absent otherwise. See banner.go.
Banner *bannerDTO `json:"banner,omitempty"`
// Email is the account's confirmed email address ("" when none); TelegramLinked and
// VkLinked report whether a platform identity is attached. They drive the profile's
// link / unlink / change-email controls, and are filled outside the pure projection
// (they read the account's identities). See Server.profileResponse.
Email string `json:"email"`
TelegramLinked bool `json:"telegram_linked"`
VkLinked bool `json:"vk_linked"`
}
// tileDTO is one placed (or to-place) tile.
+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) {
+12 -2
View File
@@ -243,8 +243,8 @@ arrive from a platform rather than completing a mandatory registration).
also carries a **one-tap confirm deeplink** (`/app/#/confirm/<token>`, an opaque
256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the
browser that opens it (magic-link), a link confirms the identity and emits a `notify`
profile-refresh to the in-app session, and a would-be merge is deferred to the
interactive flow. The confirm runs on load; the token rides the URL fragment (never
profile-refresh to the in-app session, a change switches the account's email in place,
and a would-be merge is deferred to the interactive flow. The confirm runs on load; the token rides the URL fragment (never
sent to the server), so a plain link prefetch cannot reach it, and the manual
six-digit code is the fallback if an aggressive scanner runs the page. An
**email-login** account is created flagged `is_guest` and stays reapable until the
@@ -263,6 +263,16 @@ arrive from a platform rather than completing a mandatory registration).
is revealed **only after** the proof is verified and is performed behind an
explicit, irreversible confirmation. A free identity is simply attached (and a
guest is promoted to durable, clearing `is_guest`).
- **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The
backend **refuses removing the last identity** (`ErrLastIdentity`), so an account
never becomes unreachable; the UI mirrors the guard by hiding Unlink when only one
method remains. **Email is never unlinked — it is changed.**
- **Change email** mails a confirm-code (`purpose=change`) to the new address on the
authenticated account and, on confirm (code or one-tap deeplink), **atomically
replaces** the account's email identity with the new one, freeing the old address. A
new address already confirmed by **another** account is refused **without disclosure**
(a neutral "check the address or contact support") and **never merged** — the anti-
enumeration check is only reachable by someone who controls the new mailbox.
- **Merge** retires the account that owns the linked identity into the **current**
account, in a single transaction (`internal/accountmerge`): statistics summed
(counters incl. moves/hints added, max points kept, and the per-variant best moves
+10 -4
View File
@@ -96,10 +96,6 @@ reconnect), and pending reads resume on their own — the interface stays usable
flashing a red banner each time.
### Accounts, linking & merge
_Sign-in is currently provider-only, so the in-profile linking UI is temporarily hidden; it
returns once the anonymous `/app/` guest (whose upgrade path this is) ships. The flow below
describes it for when it does._
First platform contact auto-provisions a durable account. From the profile a player
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
who links their first identity becomes a durable account. The "already taken" status
@@ -111,6 +107,16 @@ when a guest links an identity that already has a durable account, where the dur
account is kept and the guest's games move into it. A merge is blocked only while the
two accounts share a game still in progress.
The profile lists the account's **sign-in methods**. On the web a player can add
Telegram; adding VK on the web is not offered yet, and inside a Mini App the host
platform is already linked. A linked provider can be **unlinked** — except the last
remaining sign-in method, which is refused so the account stays reachable. **Email is
never unlinked; it is changed**: the player enters a new address, confirms a code
mailed to it, and the account switches to it atomically, freeing the old address. A new
address that already belongs to another account is refused with a neutral "check the
address or contact support" — the switch never merges and never reveals the other
account.
### Lobby & matchmaking
On a cold open the lobby greets the player with a brief **loading splash** — Scrabble tiles
spelling **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** as a small crossword — that clears the moment the
+10 -4
View File
@@ -100,10 +100,6 @@ Telegram держит **единого бота**: все игроки поль
рабочим вместо красного баннера каждый раз.
### Аккаунты, привязка и слияние
_Вход сейчас только через провайдера, поэтому UI привязки в профиле временно скрыт; он
вернётся, когда появится анонимный `/app/`-гость (для апгрейда которого он и нужен). Описание
ниже — на этот случай._
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
@@ -115,6 +111,16 @@ _Вход сейчас только через провайдера, поэто
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
запрещено, только пока у аккаунтов есть общая незавершённая игра.
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
Telegram; добавление VK в вебе пока не предлагается, а внутри Mini App платформа-хозяин
уже привязана. Привязанного провайдера можно **отвязать** — кроме последнего
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
**Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код,
отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая
старый. Новый адрес, уже принадлежащий другому аккаунту, отклоняется нейтральным
«проверьте правильность e-mail или обратитесь в поддержку» — смена никогда не сливает
аккаунты и не раскрывает чужой.
### Лобби и подбор
При холодном запуске лобби встречает игрока короткой **заставкой загрузки** — фишки Scrabble
складывают небольшой кроссворд из слов **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** — и она исчезает, как
+5
View File
@@ -37,6 +37,11 @@ type ProfileResp struct {
// Banner is the advertising-banner block, present only for a viewer eligible to
// see the banner. The gateway forwards it verbatim into the Profile payload.
Banner *BannerResp `json:"banner,omitempty"`
// Email is the confirmed email ("" when none); TelegramLinked/VkLinked report an
// attached platform identity — they drive the profile's link/unlink/change controls.
Email string `json:"email"`
TelegramLinked bool `json:"telegram_linked"`
VkLinked bool `json:"vk_linked"`
}
// BannerResp is the advertising-banner block of an eligible viewer's profile: the
@@ -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
+4
View File
@@ -76,6 +76,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
tz := b.CreateString(p.TimeZone)
awayStart := b.CreateString(p.AwayStart)
awayEnd := b.CreateString(p.AwayEnd)
email := b.CreateString(p.Email)
// Build the banner table (and its children) before opening Profile: FlatBuffers
// forbids a nested table while another is under construction.
var banner flatbuffers.UOffsetT
@@ -96,6 +97,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
fb.ProfileAddAwayEnd(b, awayEnd)
fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly)
fb.ProfileAddVariantPreferences(b, prefs)
fb.ProfileAddEmail(b, email)
fb.ProfileAddTelegramLinked(b, p.TelegramLinked)
fb.ProfileAddVkLinked(b, p.VkLinked)
if p.Banner != nil {
fb.ProfileAddBanner(b, banner)
}
@@ -18,6 +18,9 @@ const (
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 {
+12
View File
@@ -224,6 +224,12 @@ table Profile {
// variant_preferences is the set of game variants the player allows themselves to be
// matched into (engine.Variant labels), Erudit-first; the New Game picker is gated by it.
variant_preferences:[string];
// email is the account's confirmed email address ("" when none); telegram_linked and
// vk_linked report whether a platform identity is attached. They drive the profile's
// link / unlink / change-email controls (all added trailing — backward-compatible).
email:string;
telegram_linked:bool;
vk_linked:bool;
}
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
@@ -495,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()
}
+42 -1
View File
@@ -179,8 +179,40 @@ func (rcv *Profile) VariantPreferencesLength() int {
return 0
}
func (rcv *Profile) Email() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(30))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *Profile) TelegramLinked() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(32))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *Profile) MutateTelegramLinked(n bool) bool {
return rcv._tab.MutateBoolSlot(32, n)
}
func (rcv *Profile) VkLinked() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(34))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *Profile) MutateVkLinked(n bool) bool {
return rcv._tab.MutateBoolSlot(34, n)
}
func ProfileStart(builder *flatbuffers.Builder) {
builder.StartObject(13)
builder.StartObject(16)
}
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
@@ -224,6 +256,15 @@ func ProfileAddVariantPreferences(builder *flatbuffers.Builder, variantPreferenc
func ProfileStartVariantPreferencesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func ProfileAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(email), 0)
}
func ProfileAddTelegramLinked(builder *flatbuffers.Builder, telegramLinked bool) {
builder.PrependBoolSlot(14, telegramLinked, false)
}
func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) {
builder.PrependBoolSlot(15, vkLinked, false)
}
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+40 -16
View File
@@ -292,32 +292,56 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa
await expect(save).toBeEnabled();
});
// The email upgrade box is now shown to guests (email bind + the merge dialog). These specs
// still assume a non-guest login and the visible Telegram control, so they stay skipped until
// PR2 re-enables provider linking and adds a guest-login setup.
test.skip('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => {
// The profile's sign-in-methods matrix (email add/change + provider link/unlink). The mock
// profile is a durable account already holding an email, so the email control is the change
// flow; a new address containing "taken" stands in (in the mock) for one owned by another
// account, driving the non-disclosing refusal.
test('change email: a taken address is refused without disclosure', async ({ page }) => {
await loginLobby(page);
await openProfile(page);
// The email box is shown to guests (this spec needs a guest login — re-enabled in PR2).
await expect(page.getByRole('heading', { name: 'Link an account' })).toBeVisible();
// An address containing "merge" stands in (in the mock) for one already owned by
// another account, so the confirm step reveals a required merge.
await page.locator('.emailbox input[type="email"]').fill('merge@example.com');
await expect(page.getByText('you@example.com')).toBeVisible();
await page.getByRole('button', { name: 'Change' }).click();
await page.getByPlaceholder('New email address').fill('taken@example.com');
await page.getByRole('button', { name: 'Send code' }).click();
await page.locator('.emailbox .codein').fill('123456');
await page.locator('.accounts .codein').fill('123456');
await page.getByRole('button', { name: 'OK' }).click();
// The reveal happens only after the code, and names the other account.
await expect(page.getByText('Merge accounts?')).toBeVisible();
await expect(page.getByText(/Ann/)).toBeVisible();
await page.getByRole('button', { name: 'Merge' }).click();
await expect(page.getByText('Merge accounts?')).toBeHidden();
// The neutral message never reveals the other account.
await expect(page.getByText('Check the address or contact support.')).toBeVisible();
await expect(page.getByText(/belongs to another account/)).toHaveCount(0);
await expect(page.getByText('you@example.com')).toBeVisible();
});
test.skip('link account: the Telegram web sign-in control is offered in a browser', async ({ page }) => {
test('change email: a free address replaces the current one', async ({ page }) => {
await loginLobby(page);
await openProfile(page);
await page.getByRole('button', { name: 'Change' }).click();
await page.getByPlaceholder('New email address').fill('fresh@example.com');
await page.getByRole('button', { name: 'Send code' }).click();
await page.locator('.accounts .codein').fill('123456');
await page.getByRole('button', { name: 'OK' }).click();
await expect(page.getByText('fresh@example.com')).toBeVisible();
await expect(page.getByText('you@example.com')).toHaveCount(0);
});
test('link then unlink Telegram from the sign-in methods', async ({ page }) => {
await loginLobby(page);
await openProfile(page);
// On the web the Telegram login-widget control is offered; the mock links it instantly.
await page.getByRole('button', { name: 'Link Telegram' }).click();
const tgRow = page.locator('.acctrow').filter({ hasText: 'Telegram' });
await expect(tgRow).toBeVisible();
// With email + Telegram two methods remain, so Unlink is offered; confirm it in the dialog.
await tgRow.getByRole('button', { name: 'Unlink' }).click();
await expect(page.getByText('Unlink account?')).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Unlink' }).click();
await expect(page.locator('.acctrow').filter({ hasText: 'Telegram' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible();
});
+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);
}
}
+30 -1
View File
@@ -107,8 +107,25 @@ variantPreferencesLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
email():string|null
email(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
email(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 30);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
telegramLinked():boolean {
const offset = this.bb!.__offset(this.bb_pos, 32);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
vkLinked():boolean {
const offset = this.bb!.__offset(this.bb_pos, 34);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startProfile(builder:flatbuffers.Builder) {
builder.startObject(13);
builder.startObject(16);
}
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
@@ -175,6 +192,18 @@ static startVariantPreferencesVector(builder:flatbuffers.Builder, numElems:numbe
builder.startVector(4, numElems, 4);
}
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
builder.addFieldOffset(13, emailOffset, 0);
}
static addTelegramLinked(builder:flatbuffers.Builder, telegramLinked:boolean) {
builder.addFieldInt8(14, +telegramLinked, +false);
}
static addVkLinked(builder:flatbuffers.Builder, vkLinked:boolean) {
builder.addFieldInt8(15, +vkLinked, +false);
}
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
+8
View File
@@ -177,6 +177,14 @@ export interface GatewayClient {
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
linkTelegram(data: string): Promise<LinkResult>;
linkTelegramMerge(data: string): Promise<LinkResult>;
/** Detach a platform identity (kind 'telegram' | 'vk') from the account; email is never
* unlinked. Returns the refreshed link result (status 'unlinked'). */
linkUnlink(kind: string): Promise<LinkResult>;
/** Change the account's confirmed email: mail a code to the new address, then confirm
* to atomically switch (status 'changed'). A new address owned by another account is
* refused without disclosure. */
changeEmailRequest(email: string): Promise<void>;
changeEmailConfirm(email: string, code: string): Promise<LinkResult>;
// --- live stream ---
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
+17
View File
@@ -28,6 +28,7 @@ import {
encodeExchange,
encodeExportUrlRequest,
encodeGuestLogin,
encodeLinkUnlink,
encodeStateRequest,
encodeSubmitPlay,
encodeTarget,
@@ -458,6 +459,22 @@ describe('codec', () => {
});
});
it('encodes an unlink request carrying the provider kind', () => {
const r = fb.LinkUnlinkRequest.getRootAsLinkUnlinkRequest(new ByteBuffer(encodeLinkUnlink('telegram')));
expect(r.kind()).toBe('telegram');
});
it('passes an unlinked / changed LinkResult status straight through', () => {
for (const status of ['unlinked', 'changed'] as const) {
const b = new Builder(64);
const s = b.createString(status);
fb.LinkResult.startLinkResult(b);
fb.LinkResult.addStatus(b, s);
b.finish(fb.LinkResult.endLinkResult(b));
expect(decodeLinkResult(b.asUint8Array()).status).toBe(status);
}
});
it('decodes a merged LinkResult carrying a switched session', () => {
const b = new Builder(128);
const token = b.createString('tok-9');
+11
View File
@@ -354,6 +354,9 @@ export function decodeProfile(buf: Uint8Array): Profile {
notificationsInAppOnly: p.notificationsInAppOnly(),
variantPreferences: decodeVariantPreferences(p),
banner: decodeBanner(p),
email: s(p.email()),
telegramLinked: p.telegramLinked(),
vkLinked: p.vkLinked(),
};
}
@@ -728,6 +731,14 @@ export function encodeLinkTelegram(data: string): Uint8Array {
return finish(b, fb.LinkTelegramRequest.endLinkTelegramRequest(b));
}
export function encodeLinkUnlink(kind: string): Uint8Array {
const b = new Builder(64);
const k = b.createString(kind);
fb.LinkUnlinkRequest.startLinkUnlinkRequest(b);
fb.LinkUnlinkRequest.addKind(b, k);
return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b));
}
export function decodeLinkResult(buf: Uint8Array): LinkResult {
const r = fb.LinkResult.getRootAsLinkResult(new ByteBuffer(buf));
const sess = r.session();
+9
View File
@@ -179,6 +179,15 @@ export const en = {
'profile.mergeBody': 'This identity already belongs to “{name}” ({games} games, {friends} friends).',
'profile.mergeIrreversible': 'Merging combines both accounts into this one and cannot be undone.',
'profile.mergeConfirm': 'Merge',
'profile.accountsTitle': 'Sign-in methods',
'profile.changeEmail': 'Change',
'profile.newEmailPlaceholder': 'New email address',
'profile.emailChanged': 'Email updated.',
'profile.emailChangeTaken': 'Check the address or contact support.',
'profile.unlink': 'Unlink',
'profile.unlinked': 'Account unlinked.',
'profile.unlinkTitle': 'Unlink account?',
'profile.unlinkBody': 'Remove {provider} as a sign-in method? You can link it again later.',
'settings.title': 'Settings',
'settings.theme': 'Theme',
+9
View File
@@ -179,6 +179,15 @@ export const ru: Record<MessageKey, string> = {
'profile.mergeBody': 'Эта личность уже принадлежит «{name}» (игр: {games}, друзей: {friends}).',
'profile.mergeIrreversible': 'Объединение сольёт оба аккаунта в этот и необратимо.',
'profile.mergeConfirm': 'Объединить',
'profile.accountsTitle': 'Способы входа',
'profile.changeEmail': 'Изменить',
'profile.newEmailPlaceholder': 'Новый адрес e-mail',
'profile.emailChanged': 'E-mail изменён.',
'profile.emailChangeTaken': 'Проверьте правильность e-mail или обратитесь в поддержку.',
'profile.unlink': 'Отвязать',
'profile.unlinked': 'Аккаунт отвязан.',
'profile.unlinkTitle': 'Отвязать аккаунт?',
'profile.unlinkBody': 'Убрать {provider} как способ входа? Позже можно привязать снова.',
'settings.title': 'Настройки',
'settings.theme': 'Тема',
+18 -1
View File
@@ -656,20 +656,37 @@ export class MockGateway implements GatewayClient {
};
}
this.profile.isGuest = false;
this.profile.email = email;
return emptyLinked();
}
async linkEmailMerge(_email: string, _code: string): Promise<LinkResult> {
async linkEmailMerge(email: string, _code: string): Promise<LinkResult> {
this.profile.isGuest = false;
this.profile.email = email;
return { ...emptyLinked(), status: 'merged' };
}
async linkTelegram(_data: string): Promise<LinkResult> {
this.profile.isGuest = false;
this.profile.telegramLinked = true;
return emptyLinked();
}
async linkTelegramMerge(_data: string): Promise<LinkResult> {
this.profile.isGuest = false;
this.profile.telegramLinked = true;
return { ...emptyLinked(), status: 'merged' };
}
async linkUnlink(kind: string): Promise<LinkResult> {
if (kind === 'telegram') this.profile.telegramLinked = false;
if (kind === 'vk') this.profile.vkLinked = false;
return { ...emptyLinked(), status: 'unlinked' };
}
async changeEmailRequest(_email: string): Promise<void> {}
async changeEmailConfirm(email: string, _code: string): Promise<LinkResult> {
// An address containing "taken" stands in for one confirmed by another account, so the
// mock can drive the non-disclosing refusal (never a merge).
if (email.includes('taken')) throw new GatewayError('email_taken');
this.profile.email = email;
return { ...emptyLinked(), status: 'changed' };
}
async statsGet(): Promise<Stats> {
return { ...this.stats };
}
+3
View File
@@ -39,6 +39,9 @@ export const PROFILE: Profile = {
notificationsInAppOnly: true,
// Every variant enabled, so the mock-driven UI offers the full New Game picker.
variantPreferences: ['erudit_ru', 'scrabble_ru', 'scrabble_en'],
email: 'you@example.com',
telegramLinked: false,
vkLinked: false,
};
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
+10 -4
View File
@@ -156,6 +156,11 @@ export interface Profile {
variantPreferences: Variant[];
/** The advertising-banner block, present only for a viewer eligible to see it. */
banner?: Banner;
/** The account's confirmed email address ("" when none). */
email: string;
/** Whether a Telegram / VK identity is attached — drives the Add / Unlink controls. */
telegramLinked: boolean;
vkLinked: boolean;
}
/** Banner is the advertising-banner block of an eligible viewer's profile. */
@@ -341,13 +346,14 @@ export interface Session {
displayName: string;
}
// LinkResult is the outcome of an account link/merge step. status is
// LinkResult is the outcome of an account link/merge/unlink/change step. status is
// 'linked' (bound to the current account), 'merge_required' (the identity belongs to
// another account — the secondary* fields summarise it for the irreversible
// confirmation) or 'merged'. session is set only when the active account switched
// (a guest initiator whose durable counterpart won); the client adopts it.
// confirmation), 'merged', 'unlinked' (a provider detached) or 'changed' (the email was
// switched). session is set only when the active account switched (a guest initiator
// whose durable counterpart won); the client adopts it.
export interface LinkResult {
status: 'linked' | 'merge_required' | 'merged';
status: 'linked' | 'merge_required' | 'merged' | 'unlinked' | 'changed';
secondaryUserId: string;
secondaryDisplayName: string;
secondaryGames: number;
+9
View File
@@ -268,6 +268,15 @@ export function createTransport(baseUrl: string): GatewayClient {
async linkTelegramMerge(data) {
return codec.decodeLinkResult(await exec('link.telegram.merge', codec.encodeLinkTelegram(data)));
},
async linkUnlink(kind) {
return codec.decodeLinkResult(await exec('link.unlink', codec.encodeLinkUnlink(kind)));
},
async changeEmailRequest(email) {
await exec('link.email.change.request', codec.encodeLinkEmailRequest(email));
},
async changeEmailConfirm(email, code) {
return codec.decodeLinkResult(await exec('link.email.change.confirm', codec.encodeLinkEmailConfirm(email, code)));
},
async statsGet() {
return codec.decodeStats(await exec('stats.get', codec.empty()));
},
+185 -18
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte';
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
import { GatewayError } from '../lib/client';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
@@ -38,6 +39,10 @@
// dialog confirms it. tgData holds the Telegram widget payload for the merge step.
let pendingMerge = $state<null | { kind: 'email' | 'telegram'; name: string; games: number; friends: number }>(null);
let tgData = '';
// The change-email sub-form is open (a new address is being entered/confirmed).
let changingEmail = $state(false);
// A pending unlink awaiting the confirmation dialog (email is never unlinked).
let confirmUnlink = $state<null | { kind: 'telegram' | 'vk'; label: string }>(null);
const telegramLinkable = loginWidgetAvailable();
function defaultTz(): string {
@@ -73,6 +78,14 @@
const awayOk = $derived(awayDurationOk(awayStart, awayEnd));
const formValid = $derived(nameOk && awayOk && variantPrefs.length >= 1);
const emailOk = $derived(validEmail(emailInput));
// Count the account's confirmed sign-in methods, to hide an Unlink that would remove the
// last identity (the backend also refuses it — this is a UX guard, not the enforcement).
const linkedCount = $derived(
app.profile
? (app.profile.email ? 1 : 0) + (app.profile.telegramLinked ? 1 : 0) + (app.profile.vkLinked ? 1 : 0)
: 0,
);
const canUnlink = $derived(linkedCount >= 2);
// toggleVariant flips a variant in the preference set, refusing to remove the last one —
// the player must allow themselves at least one variant.
@@ -172,6 +185,56 @@
handleError(e);
}
}
// startChangeEmail opens the change-email sub-form on a clean slate (a new address then a
// code), independent of any half-finished add-email attempt.
function startChangeEmail() {
changingEmail = true;
emailSent = false;
emailInput = '';
codeInput = '';
}
async function requestChangeEmail() {
if (!emailOk) return;
try {
await gateway.changeEmailRequest(emailInput.trim());
emailSent = true;
showToast(t('profile.emailSent', { email: emailInput.trim() }));
} catch (e) {
handleError(e);
}
}
async function confirmChangeEmail() {
try {
const r = await gateway.changeEmailConfirm(emailInput.trim(), codeInput.trim());
await applyLinkResult(r);
populate();
changingEmail = false;
resetEmail();
showToast(t('profile.emailChanged'));
} catch (e) {
// A new address confirmed by another account is refused without disclosing it.
if (e instanceof GatewayError && e.code === 'email_taken') {
showToast(t('profile.emailChangeTaken'), 'error');
return;
}
handleError(e);
}
}
async function unlink(kind: 'telegram' | 'vk') {
confirmUnlink = null;
try {
const r = await gateway.linkUnlink(kind);
await applyLinkResult(r);
populate();
showToast(t('profile.unlinked'));
} catch (e) {
handleError(e);
}
}
</script>
<div class="page">
@@ -244,13 +307,41 @@
</form>
{/if}
<!-- The guest's email upgrade path: bind an email to register / sign in (a returning
address triggers the merge dialog below). Guest-only for now; provider linking
(Add VK / Add Telegram / Unlink) and the non-guest matrix come next, together
with the skipped linking specs in e2e/social.spec.ts. -->
<section class="emailbox" hidden={!p.isGuest}>
<h3>{t('profile.linkAccount')}</h3>
<!-- Sign-in methods: bind/change an email and link/unlink providers. A returning email
(add) triggers the merge dialog below. On the web an account can add Telegram via the
login widget; adding VK on the web is deferred (no VK OAuth) and inside a Mini App the
host provider is already linked. Email is never unlinked — it is changed. Unlink is
offered only when another identity remains (the backend also refuses the last one). -->
<section class="accounts">
<h3>{t('profile.accountsTitle')}</h3>
{#if p.email}
<div class="acctrow">
<span class="prov"><span class="ic">✉️</span>{p.email}</span>
{#if !changingEmail}
<button class="ghost" onclick={startChangeEmail} disabled={!connection.online}>{t('profile.changeEmail')}</button>
{/if}
</div>
{#if changingEmail}
{#if !emailSent}
<div class="addrow">
<input
class:invalid={emailInput.length > 0 && !emailOk}
bind:value={emailInput}
placeholder={t('profile.newEmailPlaceholder')}
type="email"
/>
<button class="ghost" onclick={requestChangeEmail} disabled={!emailOk || !connection.online}>{t('login.sendCode')}</button>
</div>
{:else}
<div class="addrow">
<input class="codein" bind:value={codeInput} placeholder={t('profile.emailCode')} inputmode="numeric" maxlength="6" />
<button class="btn" onclick={confirmChangeEmail} disabled={!connection.online}>{t('common.ok')}</button>
</div>
{/if}
<button class="linklike" onclick={() => { changingEmail = false; resetEmail(); }}>{t('common.cancel')}</button>
{/if}
{:else if !emailSent}
<div class="addrow">
<input
class:invalid={emailInput.length > 0 && !emailOk}
@@ -262,19 +353,31 @@
</div>
{:else}
<div class="addrow">
<input
class="codein"
bind:value={codeInput}
placeholder={t('profile.emailCode')}
inputmode="numeric"
maxlength="6"
/>
<input class="codein" bind:value={codeInput} placeholder={t('profile.emailCode')} inputmode="numeric" maxlength="6" />
<button class="btn" onclick={confirmEmail} disabled={!connection.online}>{t('common.ok')}</button>
</div>
{/if}
{#if telegramLinkable}
<!-- Provider linking lands with the non-guest matrix (PR2); kept wired but hidden. -->
<button class="ghost tg" hidden onclick={linkTelegram} disabled={!connection.online}>{t('profile.linkTelegram')}</button>
{#if p.telegramLinked}
<div class="acctrow">
<span class="prov"><img src="telegram-logo.svg" alt="" width="18" height="18" />Telegram</span>
{#if canUnlink}
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'telegram', label: 'Telegram' })} disabled={!connection.online}>{t('profile.unlink')}</button>
{/if}
</div>
{:else if telegramLinkable}
<button class="ghost tg" onclick={linkTelegram} disabled={!connection.online}>
<img src="telegram-logo.svg" alt="" width="18" height="18" />{t('profile.linkTelegram')}
</button>
{/if}
{#if p.vkLinked}
<div class="acctrow">
<span class="prov"><img src="vk-logo.svg" alt="" width="18" height="18" />VK</span>
{#if canUnlink}
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
{/if}
</div>
{/if}
</section>
@@ -295,6 +398,17 @@
</Modal>
{/if}
{#if confirmUnlink}
{@const cu = confirmUnlink}
<Modal title={t('profile.unlinkTitle')} onclose={() => (confirmUnlink = null)}>
<p>{t('profile.unlinkBody', { provider: cu.label })}</p>
<div class="addrow end">
<button class="ghost" onclick={() => (confirmUnlink = null)}>{t('common.cancel')}</button>
<button class="btn danger" onclick={() => unlink(cu.kind)} disabled={!connection.online}>{t('profile.unlink')}</button>
</div>
</Modal>
{/if}
<style>
.page {
padding: var(--pad);
@@ -407,11 +521,64 @@
.btn:disabled {
opacity: 0.5;
}
.emailbox h3 {
margin: 0 0 8px;
.accounts {
display: flex;
flex-direction: column;
gap: 10px;
}
.accounts h3 {
margin: 0 0 2px;
font-size: 0.95rem;
color: var(--text-muted);
}
.acctrow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
}
.prov {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.prov .ic {
width: 18px;
text-align: center;
}
.tg {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
}
.ghost.danger {
border-color: var(--danger, #c0392b);
color: var(--danger, #c0392b);
}
.btn.danger {
background: var(--danger, #c0392b);
border-color: var(--danger, #c0392b);
color: #fff;
}
.linklike {
align-self: flex-start;
background: none;
border: none;
padding: 2px 0;
color: var(--text-muted);
text-decoration: underline;
font-size: 0.85rem;
cursor: pointer;
}
.addrow {
display: flex;
gap: 8px;