diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index 64f7d53..3b65cbb 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -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 diff --git a/backend/internal/account/emailtemplate.go b/backend/internal/account/emailtemplate.go index 58a88ab..9222c2c 100644 --- a/backend/internal/account/emailtemplate.go +++ b/backend/internal/account/emailtemplate.go @@ -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. diff --git a/backend/internal/account/emailtemplate_test.go b/backend/internal/account/emailtemplate_test.go index f648528..95d0ba0 100644 --- a/backend/internal/account/emailtemplate_test.go +++ b/backend/internal/account/emailtemplate_test.go @@ -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) { diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go index 08f3b32..fe21dc7 100644 --- a/backend/internal/account/link.go +++ b/backend/internal/account/link.go @@ -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) } - 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) + 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) { diff --git a/backend/internal/inttest/link_change_test.go b/backend/internal/inttest/link_change_test.go new file mode 100644 index 0000000..d3d8c0b --- /dev/null +++ b/backend/internal/inttest/link_change_test.go @@ -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) + } +} diff --git a/backend/internal/server/banner.go b/backend/internal/server/banner.go index c87d70d..f3f5892 100644 --- a/backend/internal/server/banner.go +++ b/backend/internal/server/banner.go @@ -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) diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 9b97885..071e966 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -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. diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 8fd71da..dc442a6 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -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): diff --git a/backend/internal/server/handlers_link.go b/backend/internal/server/handlers_link.go index de37628..efbe667 100644 --- a/backend/internal/server/handlers_link.go +++ b/backend/internal/server/handlers_link.go @@ -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) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 223bc07..f1376c5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -243,8 +243,8 @@ arrive from a platform rather than completing a mandatory registration). also carries a **one-tap confirm deeplink** (`/app/#/confirm/`, 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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index ec82aef..c517b61 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 410a46a..1d9c52b 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -100,10 +100,6 @@ Telegram держит **единого бота**: все игроки поль рабочим вместо красного баннера каждый раз. ### Аккаунты, привязка и слияние -_Вход сейчас только через провайдера, поэтому UI привязки в профиле временно скрыт; он -вернётся, когда появится анонимный `/app/`-гость (для апгрейда которого он и нужен). Описание -ниже — на этот случай._ - Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость, привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже @@ -115,6 +111,16 @@ _Вход сейчас только через провайдера, поэто тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние запрещено, только пока у аккаунтов есть общая незавершённая игра. +В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить +Telegram; добавление VK в вебе пока не предлагается, а внутри Mini App платформа-хозяин +уже привязана. Привязанного провайдера можно **отвязать** — кроме последнего +оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым. +**Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код, +отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая +старый. Новый адрес, уже принадлежащий другому аккаунту, отклоняется нейтральным +«проверьте правильность e-mail или обратитесь в поддержку» — смена никогда не сливает +аккаунты и не раскрывает чужой. + ### Лобби и подбор При холодном запуске лобби встречает игрока короткой **заставкой загрузки** — фишки Scrabble складывают небольшой кроссворд из слов **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** — и она исчезает, как diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 2d4299c..2b4d019 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -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 diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index 65044ba..164edf0 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -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 diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index e2d9f8c..8cb2562 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -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) } diff --git a/gateway/internal/transcode/transcode_link.go b/gateway/internal/transcode/transcode_link.go index 546c279..5d6d3af 100644 --- a/gateway/internal/transcode/transcode_link.go +++ b/gateway/internal/transcode/transcode_link.go @@ -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 { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 5d6559f..758a212 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -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 diff --git a/pkg/fbs/scrabblefb/LinkUnlinkRequest.go b/pkg/fbs/scrabblefb/LinkUnlinkRequest.go new file mode 100644 index 0000000..1c05dd0 --- /dev/null +++ b/pkg/fbs/scrabblefb/LinkUnlinkRequest.go @@ -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() +} diff --git a/pkg/fbs/scrabblefb/Profile.go b/pkg/fbs/scrabblefb/Profile.go index 209e23e..3bb715b 100644 --- a/pkg/fbs/scrabblefb/Profile.go +++ b/pkg/fbs/scrabblefb/Profile.go @@ -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() } diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 1562d9a..69be846 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -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(); }); diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index f8b81e2..ce02ba1 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -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'; diff --git a/ui/src/gen/fbs/scrabblefb/link-unlink-request.ts b/ui/src/gen/fbs/scrabblefb/link-unlink-request.ts new file mode 100644 index 0000000..6604578 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/link-unlink-request.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/profile.ts b/ui/src/gen/fbs/scrabblefb/profile.ts index 89ef96b..84d880c 100644 --- a/ui/src/gen/fbs/scrabblefb/profile.ts +++ b/ui/src/gen/fbs/scrabblefb/profile.ts @@ -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; diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index b1767ff..3769da8 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -177,6 +177,14 @@ export interface GatewayClient { linkEmailMerge(email: string, code: string): Promise; linkTelegram(data: string): Promise; linkTelegramMerge(data: string): Promise; + /** 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; + /** 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; + changeEmailConfirm(email: string, code: string): Promise; // --- live stream --- subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 4e1b1ab..377921a 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -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'); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index e617ee2..6ff6739 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -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(); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 0223ffc..9796b94 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -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', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 068a61f..9b5503c 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -179,6 +179,15 @@ export const ru: Record = { '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': 'Тема', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 2aca5d8..28649af 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -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 { + async linkEmailMerge(email: string, _code: string): Promise { this.profile.isGuest = false; + this.profile.email = email; return { ...emptyLinked(), status: 'merged' }; } async linkTelegram(_data: string): Promise { this.profile.isGuest = false; + this.profile.telegramLinked = true; return emptyLinked(); } async linkTelegramMerge(_data: string): Promise { this.profile.isGuest = false; + this.profile.telegramLinked = true; return { ...emptyLinked(), status: 'merged' }; } + async linkUnlink(kind: string): Promise { + if (kind === 'telegram') this.profile.telegramLinked = false; + if (kind === 'vk') this.profile.vkLinked = false; + return { ...emptyLinked(), status: 'unlinked' }; + } + async changeEmailRequest(_email: string): Promise {} + async changeEmailConfirm(email: string, _code: string): Promise { + // 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 { return { ...this.stats }; } diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index 08777a0..5fdfa86 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -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 diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 28a4510..678e7c7 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -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; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 62b07a6..99bf7df 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -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())); }, diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index 8590b3a..db1b42a 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -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); 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); 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); + } + }
@@ -244,13 +307,41 @@ {/if} - -