Merge pull request 'feat(account): provider linking, unlink & email change (PR2)' (#163) from feature/email-relay-pr2 into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 18s
CI / ui (push) Successful in 1m5s
CI / conformance (push) Successful in 8s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m51s
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 18s
CI / ui (push) Successful in 1m5s
CI / conformance (push) Successful in 8s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m51s
This commit was merged in pull request #163.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -100,10 +100,6 @@ Telegram держит **единого бота**: все игроки поль
|
||||
рабочим вместо красного баннера каждый раз.
|
||||
|
||||
### Аккаунты, привязка и слияние
|
||||
_Вход сейчас только через провайдера, поэтому UI привязки в профиле временно скрыт; он
|
||||
вернётся, когда появится анонимный `/app/`-гость (для апгрейда которого он и нужен). Описание
|
||||
ниже — на этот случай._
|
||||
|
||||
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
|
||||
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
|
||||
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
|
||||
@@ -115,6 +111,16 @@ _Вход сейчас только через провайдера, поэто
|
||||
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
|
||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||
|
||||
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
||||
Telegram; добавление VK в вебе пока не предлагается, а внутри Mini App платформа-хозяин
|
||||
уже привязана. Привязанного провайдера можно **отвязать** — кроме последнего
|
||||
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
|
||||
**Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код,
|
||||
отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая
|
||||
старый. Новый адрес, уже принадлежащий другому аккаунту, отклоняется нейтральным
|
||||
«проверьте правильность e-mail или обратитесь в поддержку» — смена никогда не сливает
|
||||
аккаунты и не раскрывает чужой.
|
||||
|
||||
### Лобби и подбор
|
||||
При холодном запуске лобби встречает игрока короткой **заставкой загрузки** — фишки Scrabble
|
||||
складывают небольшой кроссворд из слов **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** — и она исчезает, как
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Тема',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user