diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go
index 9da3bc5..2b10fc8 100644
--- a/backend/internal/account/account.go
+++ b/backend/internal/account/account.go
@@ -122,21 +122,35 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
// ProvisionEmail returns the account owning the email identity externalID, creating
// it on first contact with browserTZ — the client's detected "±HH:MM" UTC offset —
-// seeded into its time zone and language seeded from the client's UI language. Like
+// seeded into its time zone, language seeded from the client's UI language, and its
+// display name seeded from the email's local part (so it is not left nameless). Like
// ProvisionByIdentity it is race-safe and leaves an existing account untouched, so a
-// returning user's saved zone and language are never overwritten. The email account is
+// returning user's saved zone, language and name are never overwritten. The email account is
// created here (the code-request step), not at the later login, so this is where its
// zone and language are seeded. It is created flagged is_guest with an unconfirmed
// email identity: an abandoned, never-confirmed login is then reaped like any guest,
// freeing the reserved address, and confirming the code clears the guest flag.
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ, language string) (Account, error) {
return s.provision(ctx, KindEmail, externalID, provisionSeed{
+ displayName: emailDisplayName(externalID),
timeZone: seedZone(browserTZ),
preferredLanguage: supportedLanguage(language),
isGuest: true,
})
}
+// emailDisplayName derives a display name from an email address — the local part
+// before '@', trimmed and capped to the column width — so a new email account is not
+// left nameless. It is only the first-contact seed; the user can rename it later.
+func emailDisplayName(email string) string {
+ local, _, _ := strings.Cut(email, "@")
+ local = strings.TrimSpace(local)
+ if r := []rune(local); len(r) > maxDisplayName {
+ local = strings.TrimRight(string(r[:maxDisplayName]), " ")
+ }
+ return local
+}
+
// supportedLanguage returns code normalised to a supported UI language ("en" or
// "ru"), or "" when it maps to neither, so a new account keeps the 'en' default. It
// accepts region-tagged codes ("ru-RU").
diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go
index e4df71f..64f7d53 100644
--- a/backend/internal/account/email.go
+++ b/backend/internal/account/email.go
@@ -5,6 +5,7 @@ import (
crand "crypto/rand"
"crypto/sha256"
"database/sql"
+ "encoding/base64"
"encoding/hex"
"errors"
"fmt"
@@ -26,6 +27,11 @@ const (
emailCodeTTL = 15 * time.Minute
// emailCodeMaxAttempts caps wrong-code submissions before a code is dead.
emailCodeMaxAttempts = 5
+ // linkTokenBytes is the entropy of a confirm deeplink token: 256 bits.
+ linkTokenBytes = 32
+ // emailConfirmPath is the SPA route the one-tap confirm deeplink opens (the token
+ // is appended). The SPA is served under /app/ behind a hash router.
+ emailConfirmPath = "/app/#/confirm/"
)
// Confirmation purposes recorded on a pending confirm-code row. They select what
@@ -92,18 +98,23 @@ func (s *EmailService) allowSend(email string) bool {
return s.limiter == nil || s.limiter.Allow(email)
}
-// issueCode generates a fresh confirm-code for (accountID, email), replaces any prior
-// pending confirmation, and mails the branded code in locale; purpose selects the
-// email wording. Only the SHA-256 hash of the code is stored.
+// issueCode generates a fresh confirm-code and one-tap deeplink token for (accountID,
+// email), replaces any prior pending confirmation, and mails the branded code in
+// locale; purpose selects the email wording and what verifying does. Only the SHA-256
+// hashes of the code and token are stored.
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string) error {
code, codeHash, err := generateCode()
if err != nil {
return err
}
- if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, s.now().Add(emailCodeTTL)); err != nil {
+ token, tokenHash, err := generateLinkToken()
+ if err != nil {
return err
}
- msg, err := renderConfirmationEmail(purpose, code, "", s.baseURL, locale)
+ if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
+ return err
+ }
+ msg, err := renderConfirmationEmail(purpose, code, s.confirmURL(token, locale), s.baseURL, locale)
if err != nil {
return err
}
@@ -111,6 +122,16 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email
return s.mailer.Send(ctx, msg)
}
+// confirmURL builds the absolute one-tap confirm deeplink for token in locale, or ""
+// when no public base URL is configured. The locale rides the fragment as ?lang so the
+// confirm screen (opened in a browser with no session) renders in the email's language.
+func (s *EmailService) confirmURL(token, locale string) string {
+ if s.baseURL == "" {
+ return ""
+ }
+ return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token + "?lang=" + normalizeLocale(locale)
+}
+
// accountLocale returns the account's preferred UI language for localising email,
// defaulting to "en" when the account cannot be loaded.
func (s *EmailService) accountLocale(ctx context.Context, accountID uuid.UUID) string {
@@ -241,6 +262,75 @@ func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (A
return s.store.GetByID(ctx, acc.ID)
}
+// LinkConfirmation is the outcome of confirming a one-tap deeplink token: what the
+// transport layer must finish. Purpose is the pending row's purpose. For a login,
+// Account is the account to sign in. For a link, Account is the account the email was
+// (or would be) attached to; NeedsMerge is set when another account (MergeOwner)
+// already owns the address, so the caller drives the interactive merge instead of a
+// plain link — the token is left unconsumed for that merge step.
+type LinkConfirmation struct {
+ Purpose string
+ Account uuid.UUID
+ NeedsMerge bool
+ MergeOwner uuid.UUID
+}
+
+// IsLogin reports whether the confirmation is a login (the caller mints a session)
+// rather than a link (attach the identity, or drive a merge).
+func (r LinkConfirmation) IsLogin() bool { return r.Purpose == purposeLogin }
+
+// ConfirmByToken verifies a one-tap deeplink token and performs its purpose. A login
+// confirms the email identity, clears the guest flag and returns the account to sign
+// in. A link attaches the confirmed email to the pending account when the address is
+// free, or reports NeedsMerge when another account already owns it (leaving the token
+// live so the caller's merge step can re-verify). It returns ErrNoPendingCode when the
+// token matches no live confirmation and ErrCodeExpired when it has lapsed. The token
+// is high-entropy, so there is no wrong-attempt counter.
+func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkConfirmation, error) {
+ pend, err := s.store.pendingByTokenHash(ctx, hashCode(token))
+ if err != nil {
+ return LinkConfirmation{}, err
+ }
+ if s.now().After(pend.expiresAt) {
+ return LinkConfirmation{}, ErrCodeExpired
+ }
+ switch pend.purpose {
+ case purposeLogin:
+ if err := s.store.confirmEmailLogin(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
+ return LinkConfirmation{}, err
+ }
+ if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
+ return LinkConfirmation{}, err
+ }
+ return LinkConfirmation{Purpose: purposeLogin, Account: pend.accountID}, nil
+ case purposeLink:
+ owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email)
+ if err != nil {
+ return LinkConfirmation{}, err
+ }
+ if ok {
+ if owner == pend.accountID {
+ if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil {
+ return LinkConfirmation{}, err
+ }
+ return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
+ }
+ return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID, NeedsMerge: true, MergeOwner: owner}, nil
+ }
+ if err := s.store.confirmEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
+ return LinkConfirmation{}, err
+ }
+ // Binding the first email promotes a guest to a durable account, matching the
+ // code-based link flow (which clears the guest flag in the link service).
+ if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
+ return LinkConfirmation{}, err
+ }
+ return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
+ default:
+ return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose)
+ }
+}
+
// emailConfirmation is a pending confirm-code row in domain form.
type emailConfirmation struct {
id uuid.UUID
@@ -271,7 +361,7 @@ func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.U
// replacePendingConfirmation clears any pending code for (accountID, email) and
// inserts a fresh one, inside one transaction.
-func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash string, expiresAt time.Time) error {
+func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash, linkTokenHash, purpose string, expiresAt time.Time) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new confirmation id: %w", err)
@@ -288,7 +378,8 @@ func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.U
ins := table.EmailConfirmations.INSERT(
table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID,
table.EmailConfirmations.Email, table.EmailConfirmations.CodeHash, table.EmailConfirmations.ExpiresAt,
- ).VALUES(id, accountID, email, codeHash, expiresAt)
+ table.EmailConfirmations.LinkTokenHash, table.EmailConfirmations.Purpose,
+ ).VALUES(id, accountID, email, codeHash, expiresAt, linkTokenHash, purpose)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert confirmation: %w", err)
}
@@ -321,6 +412,54 @@ func (s *Store) latestPendingConfirmation(ctx context.Context, accountID uuid.UU
}, nil
}
+// pendingConfirmation is a pending confirm-code row loaded by its deeplink token, in
+// domain form.
+type pendingConfirmation struct {
+ id uuid.UUID
+ accountID uuid.UUID
+ email string
+ purpose string
+ expiresAt time.Time
+}
+
+// pendingByTokenHash loads the unconsumed confirmation whose deeplink token hashes to
+// tokenHash, or ErrNoPendingCode. The high-entropy token needs no attempt counter, so
+// a partial-unique index guarantees at most one match.
+func (s *Store) pendingByTokenHash(ctx context.Context, tokenHash string) (pendingConfirmation, error) {
+ stmt := postgres.SELECT(table.EmailConfirmations.AllColumns).
+ FROM(table.EmailConfirmations).
+ WHERE(
+ table.EmailConfirmations.LinkTokenHash.EQ(postgres.String(tokenHash)).
+ AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
+ ).LIMIT(1)
+ var row model.EmailConfirmations
+ if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
+ if errors.Is(err, qrm.ErrNoRows) {
+ return pendingConfirmation{}, ErrNoPendingCode
+ }
+ return pendingConfirmation{}, fmt.Errorf("account: load confirmation by token: %w", err)
+ }
+ return pendingConfirmation{
+ id: row.ConfirmationID,
+ accountID: row.AccountID,
+ email: row.Email,
+ purpose: row.Purpose,
+ expiresAt: row.ExpiresAt,
+ }, nil
+}
+
+// consumeConfirmation marks a confirmation consumed without writing an identity, used
+// for the idempotent already-linked deeplink path.
+func (s *Store) consumeConfirmation(ctx context.Context, id uuid.UUID, now time.Time) error {
+ upd := table.EmailConfirmations.UPDATE(table.EmailConfirmations.ConsumedAt).
+ SET(postgres.TimestampzT(now)).
+ WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(id)))
+ if _, err := upd.ExecContext(ctx, s.db); err != nil {
+ return fmt.Errorf("account: consume confirmation: %w", err)
+ }
+ return nil
+}
+
// bumpConfirmationAttempts increments a code's wrong-attempt counter by one.
func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error {
stmt := table.EmailConfirmations.
@@ -414,6 +553,18 @@ func generateCode() (code, hash string, err error) {
return code, hashCode(code), nil
}
+// generateLinkToken returns a fresh opaque one-tap confirm deeplink token (URL-safe
+// base64, 256-bit) and its hex SHA-256 hash. Only the hash is stored; the token
+// travels only in the emailed link, mirroring the session-token model.
+func generateLinkToken() (token, hash string, err error) {
+ buf := make([]byte, linkTokenBytes)
+ if _, err := crand.Read(buf); err != nil {
+ return "", "", fmt.Errorf("account: generate link token: %w", err)
+ }
+ token = base64.RawURLEncoding.EncodeToString(buf)
+ return token, hashCode(token), nil
+}
+
// hashCode returns the hex-encoded SHA-256 of a confirm-code.
func hashCode(code string) string {
sum := sha256.Sum256([]byte(code))
diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go
index 1a597ed..08f3b32 100644
--- a/backend/internal/account/link.go
+++ b/backend/internal/account/link.go
@@ -2,6 +2,7 @@ package account
import (
"context"
+ "database/sql"
"errors"
"fmt"
"time"
@@ -16,6 +17,52 @@ import (
// belongs to another account; the caller turns it into a merge.
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
+// ErrLastIdentity is returned when removing an identity would leave the account with
+// 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 {
+ ids, err := s.Identities(ctx, accountID)
+ if err != nil {
+ return err
+ }
+ hasEmail, others := false, 0
+ for _, id := range ids {
+ if id.Kind == KindEmail {
+ hasEmail = true
+ } else {
+ others++
+ }
+ }
+ if !hasEmail {
+ return ErrNotFound
+ }
+ if others == 0 {
+ return ErrLastIdentity
+ }
+ 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))),
+ )
+ if _, err := delID.ExecContext(ctx, tx); err != nil {
+ return fmt.Errorf("account: delete email identity %s: %w", 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)
+ }
+ return nil
+ })
+}
+
// 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
diff --git a/backend/internal/account/userlist.go b/backend/internal/account/userlist.go
index f2c39a3..9103c9f 100644
--- a/backend/internal/account/userlist.go
+++ b/backend/internal/account/userlist.go
@@ -28,11 +28,13 @@ type UserListItem struct {
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
// one char) matched case-insensitively against the display name / any identity's external
-// id. An empty mask means no filter on that field.
+// id; EmailExact is a strict (exact) match against an account's email identity. An empty
+// value means no filter on that field.
type UserFilter struct {
Robots bool
NameMask string
ExternalIDMask string
+ EmailExact string
}
// robotExists is the correlated subquery testing whether account a is a robot.
@@ -63,6 +65,10 @@ func userListWhere(f UserFilter) (string, []any) {
args = append(args, ext)
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args))
}
+ if email := strings.ToLower(strings.TrimSpace(f.EmailExact)); email != "" {
+ args = append(args, email)
+ where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'email' AND i.external_id = $%d)`, len(args))
+ }
return where, args
}
diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml
index 9be7009..58194f6 100644
--- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml
+++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml
@@ -101,6 +101,11 @@
{{else}}
no identities (guest) {{end}}
+{{if .HasEmail}}
+
+{{end}}
Friends
diff --git a/backend/internal/adminconsole/templates/pages/users.gohtml b/backend/internal/adminconsole/templates/pages/users.gohtml
index 9a116a0..9503578 100644
--- a/backend/internal/adminconsole/templates/pages/users.gohtml
+++ b/backend/internal/adminconsole/templates/pages/users.gohtml
@@ -9,6 +9,7 @@
{{if .Robots}} {{end}}
+
Filter
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index a4db45b..ffded10 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -62,6 +62,7 @@ type UsersView struct {
Robots bool
NameMask string
ExternalIDMask string
+ EmailExact string
FilterQuery template.URL
}
@@ -161,7 +162,9 @@ type UserDetailView struct {
HasStats bool
Stats StatsRow
Identities []IdentityRow
- Games []GameRow
+ // HasEmail gates the "Erase email" action; set when the account carries an email identity.
+ HasEmail bool
+ Games []GameRow
// TelegramID and VKID are the account's platform external ids (empty when absent).
// TelegramID gates the "Send Telegram message" operator action; VKID surfaces the VK
// user id with a link to the VK profile (there is no VK messaging to drive).
diff --git a/backend/internal/inttest/adminemail_test.go b/backend/internal/inttest/adminemail_test.go
new file mode 100644
index 0000000..7f4b532
--- /dev/null
+++ b/backend/internal/inttest/adminemail_test.go
@@ -0,0 +1,83 @@
+//go:build integration
+
+package inttest
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/google/uuid"
+
+ "scrabble/backend/internal/account"
+)
+
+// TestRemoveEmailIdentity erases an account's email identity but refuses when the
+// email is the account's only identity (which would leave it unreachable).
+func TestRemoveEmailIdentity(t *testing.T) {
+ ctx := context.Background()
+ store := account.NewStore(testDB)
+
+ // Email is the only identity → refuse.
+ solo, err := store.ProvisionEmail(ctx, "solo-"+uuid.NewString()+"@example.com", "", "en")
+ if err != nil {
+ t.Fatalf("provision email: %v", err)
+ }
+ if err := store.RemoveEmailIdentity(ctx, solo.ID); !errors.Is(err, account.ErrLastIdentity) {
+ t.Fatalf("remove last identity = %v, want ErrLastIdentity", err)
+ }
+
+ // Telegram + email → erase the email, keep Telegram.
+ tg, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
+ if err != nil {
+ t.Fatalf("provision telegram: %v", err)
+ }
+ if err := store.AttachIdentity(ctx, tg.ID, account.KindEmail, "dual-"+uuid.NewString()+"@example.com", true); err != nil {
+ t.Fatalf("attach email: %v", err)
+ }
+ if err := store.RemoveEmailIdentity(ctx, tg.ID); err != nil {
+ t.Fatalf("remove email: %v", err)
+ }
+ ids, err := store.Identities(ctx, tg.ID)
+ if err != nil {
+ t.Fatalf("identities: %v", err)
+ }
+ if len(ids) != 1 || ids[0].Kind != account.KindTelegram {
+ t.Errorf("identities after erase = %+v, want only telegram", ids)
+ }
+}
+
+// TestListUsersEmailExact matches accounts strictly (exactly) by their email identity.
+func TestListUsersEmailExact(t *testing.T) {
+ ctx := context.Background()
+ store := account.NewStore(testDB)
+ email := "find-" + uuid.NewString() + "@example.com"
+ acc, err := store.ProvisionEmail(ctx, email, "", "en")
+ if err != nil {
+ t.Fatalf("provision: %v", err)
+ }
+
+ items, err := store.ListUsers(ctx, account.UserFilter{EmailExact: email}, 50, 0)
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ found := false
+ for _, it := range items {
+ if it.ID == acc.ID {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("the exact email filter did not find the account")
+ }
+
+ other, err := store.ListUsers(ctx, account.UserFilter{EmailExact: "nope-" + uuid.NewString() + "@example.com"}, 50, 0)
+ if err != nil {
+ t.Fatalf("list (no match): %v", err)
+ }
+ for _, it := range other {
+ if it.ID == acc.ID {
+ t.Error("a non-matching email filter must not return the account")
+ }
+ }
+}
diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go
index 2e64761..667edd1 100644
--- a/backend/internal/inttest/email_test.go
+++ b/backend/internal/inttest/email_test.go
@@ -278,3 +278,148 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
t.Error("confirming the login must clear the guest flag (promote to durable)")
}
}
+
+// confirmToken extracts the one-tap deeplink token from the /app/#/confirm/ link
+// the branded email carries.
+var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`)
+
+func tokenFromMail(t *testing.T, body string) string {
+ t.Helper()
+ m := confirmToken.FindStringSubmatch(body)
+ if m == nil {
+ t.Fatalf("no confirm token in mail body %q", body)
+ }
+ return m[1]
+}
+
+// TestConfirmByTokenLogin: the one-tap deeplink token completes an email login,
+// clearing the guest flag.
+func TestConfirmByTokenLogin(t *testing.T) {
+ ctx := context.Background()
+ store := account.NewStore(testDB)
+ mailer := &capturingMailer{}
+ svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
+ email := "tok-login-" + uuid.NewString() + "@example.com"
+
+ id, err := svc.RequestLoginCode(ctx, email, "", "en")
+ if err != nil {
+ t.Fatalf("request login: %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.Account != id {
+ t.Fatalf("login result = %+v, want login for %s", res, id)
+ }
+ if !identityConfirmed(t, account.KindEmail, email) {
+ t.Error("email identity must be confirmed after the token login")
+ }
+ if acc, _ := store.GetByID(ctx, id); acc.IsGuest {
+ t.Error("the token login must clear the guest flag")
+ }
+}
+
+// TestConfirmByTokenLink: the token attaches a free email to the requesting account.
+func TestConfirmByTokenLink(t *testing.T) {
+ ctx := context.Background()
+ store := account.NewStore(testDB)
+ mailer := &capturingMailer{}
+ svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
+ acc := provisionAccount(t)
+ email := "tok-link-" + uuid.NewString() + "@example.com"
+
+ if err := svc.RequestLinkCode(ctx, acc, email); err != nil {
+ t.Fatalf("request link: %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 {
+ t.Fatalf("link result = %+v, want a plain link for %s", res, acc)
+ }
+ if !identityConfirmed(t, account.KindEmail, email) {
+ t.Error("email identity must be confirmed after the token link")
+ }
+}
+
+// TestConfirmByTokenLinkMerge: a token for an address owned by another account signals
+// a merge (leaving the token unconsumed for the interactive step).
+func TestConfirmByTokenLinkMerge(t *testing.T) {
+ ctx := context.Background()
+ store := account.NewStore(testDB)
+ mailer := &capturingMailer{}
+ svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
+ email := "tok-merge-" + uuid.NewString() + "@example.com"
+
+ owner := provisionAccount(t)
+ if err := svc.RequestCode(ctx, owner, email); err != nil {
+ t.Fatalf("owner request: %v", err)
+ }
+ if _, err := svc.ConfirmCode(ctx, owner, email, sixDigit.FindString(mailer.lastBody)); err != nil {
+ t.Fatalf("owner confirm: %v", err)
+ }
+
+ other := provisionAccount(t)
+ if err := svc.RequestLinkCode(ctx, other, email); err != nil {
+ t.Fatalf("link request: %v", err)
+ }
+ res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
+ if err != nil {
+ t.Fatalf("confirm by token: %v", err)
+ }
+ if !res.NeedsMerge || res.MergeOwner != owner {
+ t.Fatalf("merge result = %+v, want NeedsMerge with owner=%s", res, owner)
+ }
+}
+
+// TestEmailAccountSeedsDisplayName seeds a new email account's display name from the
+// email's local part, so an email login is not left nameless.
+func TestEmailAccountSeedsDisplayName(t *testing.T) {
+ ctx := context.Background()
+ store := account.NewStore(testDB)
+ svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru")
+ local := "kaya-" + uuid.NewString()[:8]
+
+ id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en")
+ if err != nil {
+ t.Fatalf("request login: %v", err)
+ }
+ acc, err := store.GetByID(ctx, id)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if acc.DisplayName != local {
+ t.Errorf("display name = %q, want the email local part %q", acc.DisplayName, local)
+ }
+}
+
+// TestConfirmByTokenLinkClearsGuest: binding an email to a guest via the deeplink
+// promotes the guest to a durable account (the deeplink path must match the
+// code-based link flow, which clears the guest flag).
+func TestConfirmByTokenLinkClearsGuest(t *testing.T) {
+ ctx := context.Background()
+ store := account.NewStore(testDB)
+ mailer := &capturingMailer{}
+ svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
+ guest, err := store.ProvisionGuest(ctx, "")
+ if err != nil {
+ t.Fatalf("provision guest: %v", err)
+ }
+ email := "guest-link-" + uuid.NewString() + "@example.com"
+
+ if err := svc.RequestLinkCode(ctx, guest.ID, email); err != nil {
+ t.Fatalf("request link: %v", err)
+ }
+ if _, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody)); err != nil {
+ t.Fatalf("confirm by token: %v", err)
+ }
+ acc, err := store.GetByID(ctx, guest.ID)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if acc.IsGuest {
+ t.Error("linking an email via the deeplink must promote the guest to durable")
+ }
+}
diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go
index 795f287..7fdf6e4 100644
--- a/backend/internal/notify/events.go
+++ b/backend/internal/notify/events.go
@@ -155,6 +155,13 @@ func Notification(userID uuid.UUID, kind string) Intent {
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
}
+// ProfileChanged is a payload-free "re-fetch your profile" signal to userID, emitted
+// when the viewer's own account changed out of band — an email confirmed through the
+// one-tap deeplink opened in another browser.
+func ProfileChanged(userID uuid.UUID) Intent {
+ return Notification(userID, NotifyProfile)
+}
+
// NotificationAccount builds a lobby notification of one of the friend_* kinds carrying the
// account it concerns (the requester, the new friend or the decliner), so the client updates its
// requests/friends lists and the in-game "add friend" state without a refetch.
diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go
index 4b2204a..e95f0f2 100644
--- a/backend/internal/notify/notify.go
+++ b/backend/internal/notify/notify.go
@@ -69,6 +69,10 @@ const (
// it re-fetches profile.get to show or hide the banner. It carries no payload (the
// banner set rides the profile response). In-app only.
NotifyBanner = "banner"
+ // NotifyProfile tells the client that the viewer's own profile changed out of band
+ // (e.g. an email was confirmed via the one-tap deeplink opened in another browser),
+ // so it re-fetches profile.get. It carries no payload. In-app only.
+ NotifyProfile = "profile"
// NotifyUserBlocked confirms to the blocker that a per-user block took effect,
// carrying the blocked account, so every one of the blocker's sessions updates the
// in-game block/add-friend controls and the struck name in place. It is delivered
diff --git a/backend/internal/postgres/jet/backend/model/email_confirmations.go b/backend/internal/postgres/jet/backend/model/email_confirmations.go
index d0bf3c9..4ba4a0a 100644
--- a/backend/internal/postgres/jet/backend/model/email_confirmations.go
+++ b/backend/internal/postgres/jet/backend/model/email_confirmations.go
@@ -21,4 +21,6 @@ type EmailConfirmations struct {
Attempts int16
ConsumedAt *time.Time
CreatedAt time.Time
+ Purpose string
+ LinkTokenHash *string
}
diff --git a/backend/internal/postgres/jet/backend/table/email_confirmations.go b/backend/internal/postgres/jet/backend/table/email_confirmations.go
index 0ddfd0c..35ea754 100644
--- a/backend/internal/postgres/jet/backend/table/email_confirmations.go
+++ b/backend/internal/postgres/jet/backend/table/email_confirmations.go
@@ -25,6 +25,8 @@ type emailConfirmationsTable struct {
Attempts postgres.ColumnInteger
ConsumedAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
+ Purpose postgres.ColumnString
+ LinkTokenHash postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -74,9 +76,11 @@ func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailCo
AttemptsColumn = postgres.IntegerColumn("attempts")
ConsumedAtColumn = postgres.TimestampzColumn("consumed_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
- allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
- mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
- defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn}
+ PurposeColumn = postgres.StringColumn("purpose")
+ LinkTokenHashColumn = postgres.StringColumn("link_token_hash")
+ allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn, PurposeColumn, LinkTokenHashColumn}
+ mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn, PurposeColumn, LinkTokenHashColumn}
+ defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn, PurposeColumn}
)
return emailConfirmationsTable{
@@ -91,6 +95,8 @@ func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailCo
Attempts: AttemptsColumn,
ConsumedAt: ConsumedAtColumn,
CreatedAt: CreatedAtColumn,
+ Purpose: PurposeColumn,
+ LinkTokenHash: LinkTokenHashColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
diff --git a/backend/internal/postgres/migrations/00006_email_confirm_deeplink.sql b/backend/internal/postgres/migrations/00006_email_confirm_deeplink.sql
new file mode 100644
index 0000000..2212f6c
--- /dev/null
+++ b/backend/internal/postgres/migrations/00006_email_confirm_deeplink.sql
@@ -0,0 +1,27 @@
+-- Add the confirm deeplink token and the confirmation purpose to email_confirmations.
+-- purpose tags what verifying the code/token does (email login, identity link, email
+-- change, or account deletion); link_token_hash holds the SHA-256 hex of the one-click
+-- deeplink token (only the hash is stored, mirroring the session-token model).
+-- Expand-contract: both columns are additive. purpose gets a NOT NULL DEFAULT so
+-- existing rows fill in; link_token_hash is nullable with a partial-unique index. A
+-- backend image rollback stays DB-safe — older code simply ignores the new columns. The
+-- table shape changes, so the generated go-jet model for this table is regenerated.
+
+-- +goose Up
+ALTER TABLE backend.email_confirmations
+ ADD COLUMN purpose text NOT NULL DEFAULT 'link',
+ ADD COLUMN link_token_hash text;
+ALTER TABLE backend.email_confirmations
+ ADD CONSTRAINT email_confirmations_purpose_chk
+ CHECK ((purpose = ANY (ARRAY['login'::text, 'link'::text, 'change'::text, 'delete'::text])));
+CREATE UNIQUE INDEX email_confirmations_link_token_hash_key
+ ON backend.email_confirmations (link_token_hash)
+ WHERE link_token_hash IS NOT NULL;
+
+-- +goose Down
+DROP INDEX IF EXISTS backend.email_confirmations_link_token_hash_key;
+ALTER TABLE backend.email_confirmations
+ DROP CONSTRAINT IF EXISTS email_confirmations_purpose_chk;
+ALTER TABLE backend.email_confirmations
+ DROP COLUMN IF EXISTS link_token_hash,
+ DROP COLUMN IF EXISTS purpose;
diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go
index b87a2d0..8fd71da 100644
--- a/backend/internal/server/handlers.go
+++ b/backend/internal/server/handlers.go
@@ -31,6 +31,7 @@ func (s *Server) registerRoutes() {
in.POST("/sessions/guest", s.handleGuestAuth)
in.POST("/sessions/email/request", s.handleEmailRequest)
in.POST("/sessions/email/login", s.handleEmailLogin)
+ in.POST("/sessions/email/confirm-link", s.handleEmailConfirmLink)
in.POST("/sessions/resolve", s.handleResolveSession)
in.POST("/sessions/revoke", s.handleRevokeSession)
// Out-of-app push routing for the platform side-service: the
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index 2ed1902..affda86 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -61,6 +61,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
+ gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
gm.GET("/reasons", s.consoleReasons)
gm.POST("/reasons", s.consoleCreateReason)
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
@@ -134,6 +135,7 @@ func (s *Server) consoleUsers(c *gin.Context) {
Robots: c.Query("kind") == "robots",
NameMask: c.Query("name"),
ExternalIDMask: c.Query("ext"),
+ EmailExact: c.Query("email"),
}
total, _ := s.accounts.CountUsers(ctx, filter)
items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize)
@@ -151,9 +153,13 @@ func (s *Server) consoleUsers(c *gin.Context) {
if strings.TrimSpace(filter.ExternalIDMask) != "" {
q.Set("ext", filter.ExternalIDMask)
}
+ if strings.TrimSpace(filter.EmailExact) != "" {
+ q.Set("email", filter.EmailExact)
+ }
view := adminconsole.UsersView{
Pager: adminconsole.NewPager(page, adminPageSize, total),
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
+ EmailExact: filter.EmailExact,
FilterQuery: template.URL(q.Encode()),
}
ids := make([]uuid.UUID, 0, len(items))
@@ -356,6 +362,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
}
if ids, err := s.accounts.Identities(ctx, id); err == nil {
for _, idn := range ids {
+ if idn.Kind == account.KindEmail {
+ view.HasEmail = true
+ }
view.Identities = append(view.Identities, adminconsole.IdentityRow{Kind: idn.Kind, ExternalID: idn.ExternalID, Confirmed: idn.Confirmed, CreatedAt: fmtTime(idn.CreatedAt)})
}
}
@@ -951,6 +960,25 @@ func (s *Server) consoleGrantHints(c *gin.Context) {
s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back)
}
+// consoleRemoveEmail deletes the account's bound email identity (and any pending
+// confirmations), freeing the address. It refuses to remove the account's only
+// identity, which would leave it unreachable.
+func (s *Server) consoleRemoveEmail(c *gin.Context) {
+ id, ok := s.consoleUUID(c, "/_gm/users")
+ if !ok {
+ return
+ }
+ back := "/_gm/users/" + id.String()
+ switch err := s.accounts.RemoveEmailIdentity(c.Request.Context(), id); {
+ case errors.Is(err, account.ErrLastIdentity):
+ s.renderConsoleMessage(c, "Can't remove", "the email is this account's only identity — removing it would leave the account unreachable", back)
+ case err != nil:
+ s.consoleError(c, err)
+ default:
+ s.renderConsoleMessage(c, "Removed", "the email identity was erased and the address freed", back)
+ }
+}
+
// consoleBlockUser manually blocks an account: it records the suspension (permanent or until a
// parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active
// games, removing them from matchmaking. The block takes effect on the player's next request.
diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go
index a9b556c..edfc8a5 100644
--- a/backend/internal/server/handlers_auth.go
+++ b/backend/internal/server/handlers_auth.go
@@ -9,6 +9,7 @@ import (
"go.uber.org/zap"
"scrabble/backend/internal/account"
+ "scrabble/backend/internal/notify"
)
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
@@ -212,6 +213,56 @@ func (s *Server) handleEmailLogin(c *gin.Context) {
s.mintSession(c, acc)
}
+// confirmLinkResponse is the outcome of a one-tap deeplink confirmation. For a login,
+// Session carries the freshly minted credential (the deeplink page signs in with it);
+// for a link, Status is "confirmed" or "merge_required" (the app completes the merge).
+type confirmLinkResponse struct {
+ Purpose string `json:"purpose"`
+ Status string `json:"status"`
+ Session *sessionResponse `json:"session,omitempty"`
+}
+
+// handleEmailConfirmLink verifies a one-tap deeplink token. A login mints a session
+// (the deeplink page signs in with it); a link attaches the confirmed email, reporting
+// "merge_required" when the address is owned by another account so the app drives the
+// interactive merge. The token, not a request session, is the authorization — the page
+// need not be signed in.
+func (s *Server) handleEmailConfirmLink(c *gin.Context) {
+ var req tokenRequest
+ if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
+ abortBadRequest(c, "token is required")
+ return
+ }
+ res, err := s.emails.ConfirmByToken(c.Request.Context(), req.Token)
+ if err != nil {
+ s.abortErr(c, err)
+ return
+ }
+ if res.IsLogin() {
+ acc, err := s.accounts.GetByID(c.Request.Context(), res.Account)
+ if err != nil {
+ s.abortErr(c, err)
+ return
+ }
+ token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
+ if err != nil {
+ s.abortErr(c, err)
+ return
+ }
+ sess := sessionResponseFor(token, acc)
+ c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "login", Status: "confirmed", Session: &sess})
+ return
+ }
+ if res.NeedsMerge {
+ c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: "merge_required"})
+ return
+ }
+ // A free link attached the email; nudge the account's in-app session(s) to re-fetch
+ // the profile, so a link confirmed in another browser reflects at once.
+ s.notifier.Publish(notify.ProfileChanged(res.Account))
+ c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: "confirmed"})
+}
+
// tokenRequest carries an opaque session token.
type tokenRequest struct {
Token string `json:"token"`
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 182212f..223bc07 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -239,7 +239,14 @@ arrive from a platform rather than completing a mandatory registration).
development log mailer when no relay is configured) and, once
verified, attaches a confirmed email identity. Sends are throttled per recipient
(a 60-second cooldown and a five-per-hour cap). Links in the email are built from
- a configured public base URL, never a request Host header (anti-injection). An
+ a configured public base URL, never a request Host header (anti-injection). The email
+ 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
+ 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
code is confirmed — so an abandoned, never-confirmed login frees its reserved
address — with confirming clearing the flag. Accounts and identities use
@@ -933,7 +940,10 @@ edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are o
eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets
`paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal),
and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content*
-edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session.
+edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. The same
+mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's
+own account changed out of band (an email confirmed through the one-tap deeplink opened in another
+browser), so an open in-app session reflects it at once.
> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md
index 09dfed6..ec82aef 100644
--- a/docs/FUNCTIONAL.md
+++ b/docs/FUNCTIONAL.md
@@ -66,7 +66,11 @@ client's light/dark scheme, and the layout clears the VK mobile home bar. The sa
is provisioned on the first request but only becomes a durable account once the code
is confirmed, so an abandoned, never-confirmed attempt is cleaned up and its address
freed. Sends are rate-limited (a short cooldown between codes and a small hourly cap
-per address), so a mistyped address or an impatient tap cannot flood an inbox.
+per address), so a mistyped address or an impatient tap cannot flood an inbox. The email
+also carries a **one-tap link** that confirms the address — or signs the player straight
+in — in a single tap; opening it in another browser reflects in the app at once, and a
+mail scanner that merely fetches the link cannot reach it — the token rides the URL
+fragment, which is never sent to the server.
Telegram runs a **single bot**: every player uses
the same bot, and all of its chat and out-of-app notifications are written in the
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md
index 1ca51cd..410a46a 100644
--- a/docs/FUNCTIONAL_ru.md
+++ b/docs/FUNCTIONAL_ru.md
@@ -71,7 +71,10 @@ launch-параметрам VK (их проверяет gateway), и при пе
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая
пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап
-не завалили почтовый ящик.
+не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
+адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
+приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
+он едет в URL-фрагменте, который не уходит на сервер.
Telegram держит **единого бота**: все игроки пользуются одним
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go
index 9ee4bd0..2d4299c 100644
--- a/gateway/internal/backendclient/api.go
+++ b/gateway/internal/backendclient/api.go
@@ -276,9 +276,9 @@ func (c *Client) GuestAuth(ctx context.Context, browserTz string) (SessionResp,
// EmailRequest asks the backend to mail a login code, provisioning the account on
// first contact; browserTz (the client's detected "±HH:MM" UTC offset) seeds the new
// account's time zone, since the email account is created here, not at login.
-func (c *Client) EmailRequest(ctx context.Context, email, browserTz string) error {
+func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string) error {
return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "",
- map[string]string{"email": email, "browser_tz": browserTz}, nil)
+ map[string]string{"email": email, "browser_tz": browserTz, "language": language}, nil)
}
// EmailLogin verifies a login code and mints a session.
@@ -289,6 +289,23 @@ func (c *Client) EmailLogin(ctx context.Context, email, code string) (SessionRes
return out, err
}
+// ConfirmLinkResp is the backend's outcome of a one-tap deeplink confirmation: for a
+// login Session carries the minted credential; for a link Status is "confirmed" or
+// "merge_required".
+type ConfirmLinkResp struct {
+ Purpose string `json:"purpose"`
+ Status string `json:"status"`
+ Session *SessionResp `json:"session,omitempty"`
+}
+
+// EmailConfirmLink verifies a one-tap deeplink token; the token is the authorization.
+func (c *Client) EmailConfirmLink(ctx context.Context, token string) (ConfirmLinkResp, error) {
+ var out ConfirmLinkResp
+ err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/confirm-link", "", "",
+ map[string]string{"token": token}, &out)
+ return out, err
+}
+
// ResolveSession maps a token to its account id and guest flag (gateway
// session-cache miss). The guest flag lets the edge gate guest-forbidden ops.
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, error) {
diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go
index 7ef473f..e2d9f8c 100644
--- a/gateway/internal/transcode/encode.go
+++ b/gateway/internal/transcode/encode.go
@@ -37,6 +37,35 @@ func encodeAck(ok bool) []byte {
return b.FinishedBytes()
}
+// encodeConfirmLinkResult builds an EmailConfirmLinkResult payload, embedding the
+// minted Session for a login. All strings and the nested Session table are built
+// before the result table is opened.
+func encodeConfirmLinkResult(r backendclient.ConfirmLinkResp) []byte {
+ b := flatbuffers.NewBuilder(160)
+ purpose := b.CreateString(r.Purpose)
+ status := b.CreateString(r.Status)
+ var session flatbuffers.UOffsetT
+ if r.Session != nil {
+ token := b.CreateString(r.Session.Token)
+ uid := b.CreateString(r.Session.UserID)
+ name := b.CreateString(r.Session.DisplayName)
+ fb.SessionStart(b)
+ fb.SessionAddToken(b, token)
+ fb.SessionAddUserId(b, uid)
+ fb.SessionAddIsGuest(b, r.Session.IsGuest)
+ fb.SessionAddDisplayName(b, name)
+ session = fb.SessionEnd(b)
+ }
+ fb.EmailConfirmLinkResultStart(b)
+ fb.EmailConfirmLinkResultAddPurpose(b, purpose)
+ fb.EmailConfirmLinkResultAddStatus(b, status)
+ if r.Session != nil {
+ fb.EmailConfirmLinkResultAddSession(b, session)
+ }
+ b.Finish(fb.EmailConfirmLinkResultEnd(b))
+ return b.FinishedBytes()
+}
+
// encodeProfile builds a Profile payload, including the advertising-banner block
// when the backend marked the viewer eligible.
func encodeProfile(p backendclient.ProfileResp) []byte {
diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go
index 26c775f..9a99a97 100644
--- a/gateway/internal/transcode/transcode.go
+++ b/gateway/internal/transcode/transcode.go
@@ -19,37 +19,38 @@ import (
// Message types in the vertical slice.
const (
- MsgAuthTelegram = "auth.telegram"
- MsgAuthVK = "auth.vk"
- MsgAuthGuest = "auth.guest"
- MsgAuthEmailReq = "auth.email.request"
- MsgAuthEmailLogin = "auth.email.login"
- MsgProfileGet = "profile.get"
- MsgBlockStatus = "account.block_status"
- MsgGameSubmitPlay = "game.submit_play"
- MsgGameState = "game.state"
- MsgLobbyEnqueue = "lobby.enqueue"
- MsgLobbyCancel = "lobby.cancel"
- MsgLobbyPoll = "lobby.poll"
- MsgChatPost = "chat.post"
- MsgGamesList = "games.list"
- MsgGamePass = "game.pass"
- MsgGameExchange = "game.exchange"
- MsgGameResign = "game.resign"
- MsgGameHint = "game.hint"
- MsgGameEvaluate = "game.evaluate"
- MsgGameCheckWord = "game.check_word"
- MsgGameComplaint = "game.complaint"
- MsgGameHistory = "game.history"
- MsgChatList = "chat.list"
- MsgChatNudge = "chat.nudge"
- MsgChatRead = "chat.read"
- MsgDraftGet = "draft.get"
- MsgDraftSave = "draft.save"
- MsgGameHide = "game.hide"
- MsgFeedbackSubmit = "feedback.submit"
- MsgFeedbackGet = "feedback.get"
- MsgFeedbackUnread = "feedback.unread"
+ MsgAuthTelegram = "auth.telegram"
+ MsgAuthVK = "auth.vk"
+ MsgAuthGuest = "auth.guest"
+ MsgAuthEmailReq = "auth.email.request"
+ MsgAuthEmailLogin = "auth.email.login"
+ MsgAuthEmailConfirmLink = "auth.email.confirm_link"
+ MsgProfileGet = "profile.get"
+ MsgBlockStatus = "account.block_status"
+ MsgGameSubmitPlay = "game.submit_play"
+ MsgGameState = "game.state"
+ MsgLobbyEnqueue = "lobby.enqueue"
+ MsgLobbyCancel = "lobby.cancel"
+ MsgLobbyPoll = "lobby.poll"
+ MsgChatPost = "chat.post"
+ MsgGamesList = "games.list"
+ MsgGamePass = "game.pass"
+ MsgGameExchange = "game.exchange"
+ MsgGameResign = "game.resign"
+ MsgGameHint = "game.hint"
+ MsgGameEvaluate = "game.evaluate"
+ MsgGameCheckWord = "game.check_word"
+ MsgGameComplaint = "game.complaint"
+ MsgGameHistory = "game.history"
+ MsgChatList = "chat.list"
+ MsgChatNudge = "chat.nudge"
+ MsgChatRead = "chat.read"
+ MsgDraftGet = "draft.get"
+ MsgDraftSave = "draft.save"
+ MsgGameHide = "game.hide"
+ MsgFeedbackSubmit = "feedback.submit"
+ MsgFeedbackGet = "feedback.get"
+ MsgFeedbackUnread = "feedback.unread"
)
// Request is one decoded Execute call.
@@ -101,6 +102,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Op
r.ops[MsgAuthGuest] = Op{Handler: authGuestHandler(backend)}
r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true}
r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend), Email: true}
+ r.ops[MsgAuthEmailConfirmLink] = Op{Handler: authEmailConfirmLinkHandler(backend)}
r.ops[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true}
r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true}
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
@@ -238,7 +240,7 @@ func authGuestHandler(backend *backendclient.Client) Handler {
func authEmailRequestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEmailRequestRequest(req.Payload, 0)
- if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz())); err != nil {
+ if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz()), string(in.Language())); err != nil {
return nil, err
}
return encodeAck(true), nil
@@ -256,6 +258,17 @@ func authEmailLoginHandler(backend *backendclient.Client) Handler {
}
}
+func authEmailConfirmLinkHandler(backend *backendclient.Client) Handler {
+ return func(ctx context.Context, req Request) ([]byte, error) {
+ in := fb.GetRootAsEmailConfirmLinkRequest(req.Payload, 0)
+ res, err := backend.EmailConfirmLink(ctx, string(in.Token()))
+ if err != nil {
+ return nil, err
+ }
+ return encodeConfirmLinkResult(res), nil
+ }
+}
+
func profileHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
p, err := backend.Profile(ctx, req.UserID)
diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs
index de830fc..5d6559f 100644
--- a/pkg/fbs/scrabble.fbs
+++ b/pkg/fbs/scrabble.fbs
@@ -132,10 +132,12 @@ table GuestLoginRequest {
// EmailRequestRequest asks the backend to send a login confirm-code to email. It
// also provisions the account on first contact, so browser_tz (the detected UTC
-// offset) is seeded into its time zone here, not at the later login step.
+// offset) is seeded into its time zone here, not at the later login step; language
+// (the client's UI language) seeds the new account's language and localises the email.
table EmailRequestRequest {
email:string;
browser_tz:string;
+ language:string;
}
// EmailLoginRequest logs in to the account owning email (provisioned at the
@@ -145,6 +147,21 @@ table EmailLoginRequest {
code:string;
}
+// EmailConfirmLinkRequest verifies a one-tap deeplink token from a confirmation
+// email. The token, not a session, is the authorization.
+table EmailConfirmLinkRequest {
+ token:string;
+}
+
+// EmailConfirmLinkResult is the outcome of a deeplink confirmation: purpose is
+// "login" or "link"; for a login session carries the minted credential; for a link
+// status is "confirmed" or "merge_required" (the app completes the merge).
+table EmailConfirmLinkResult {
+ purpose:string;
+ status:string;
+ session:Session;
+}
+
// Session is the minted credential returned by every auth operation.
table Session {
token:string;
diff --git a/pkg/fbs/scrabblefb/EmailConfirmLinkRequest.go b/pkg/fbs/scrabblefb/EmailConfirmLinkRequest.go
new file mode 100644
index 0000000..fbeec41
--- /dev/null
+++ b/pkg/fbs/scrabblefb/EmailConfirmLinkRequest.go
@@ -0,0 +1,60 @@
+// Code generated by the FlatBuffers compiler. DO NOT EDIT.
+
+package scrabblefb
+
+import (
+ flatbuffers "github.com/google/flatbuffers/go"
+)
+
+type EmailConfirmLinkRequest struct {
+ _tab flatbuffers.Table
+}
+
+func GetRootAsEmailConfirmLinkRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmLinkRequest {
+ n := flatbuffers.GetUOffsetT(buf[offset:])
+ x := &EmailConfirmLinkRequest{}
+ x.Init(buf, n+offset)
+ return x
+}
+
+func FinishEmailConfirmLinkRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
+ builder.Finish(offset)
+}
+
+func GetSizePrefixedRootAsEmailConfirmLinkRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmLinkRequest {
+ n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
+ x := &EmailConfirmLinkRequest{}
+ x.Init(buf, n+offset+flatbuffers.SizeUint32)
+ return x
+}
+
+func FinishSizePrefixedEmailConfirmLinkRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
+ builder.FinishSizePrefixed(offset)
+}
+
+func (rcv *EmailConfirmLinkRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
+ rcv._tab.Bytes = buf
+ rcv._tab.Pos = i
+}
+
+func (rcv *EmailConfirmLinkRequest) Table() flatbuffers.Table {
+ return rcv._tab
+}
+
+func (rcv *EmailConfirmLinkRequest) Token() []byte {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
+ if o != 0 {
+ return rcv._tab.ByteVector(o + rcv._tab.Pos)
+ }
+ return nil
+}
+
+func EmailConfirmLinkRequestStart(builder *flatbuffers.Builder) {
+ builder.StartObject(1)
+}
+func EmailConfirmLinkRequestAddToken(builder *flatbuffers.Builder, token flatbuffers.UOffsetT) {
+ builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(token), 0)
+}
+func EmailConfirmLinkRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
+ return builder.EndObject()
+}
diff --git a/pkg/fbs/scrabblefb/EmailConfirmLinkResult.go b/pkg/fbs/scrabblefb/EmailConfirmLinkResult.go
new file mode 100644
index 0000000..78ef0dd
--- /dev/null
+++ b/pkg/fbs/scrabblefb/EmailConfirmLinkResult.go
@@ -0,0 +1,87 @@
+// Code generated by the FlatBuffers compiler. DO NOT EDIT.
+
+package scrabblefb
+
+import (
+ flatbuffers "github.com/google/flatbuffers/go"
+)
+
+type EmailConfirmLinkResult struct {
+ _tab flatbuffers.Table
+}
+
+func GetRootAsEmailConfirmLinkResult(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmLinkResult {
+ n := flatbuffers.GetUOffsetT(buf[offset:])
+ x := &EmailConfirmLinkResult{}
+ x.Init(buf, n+offset)
+ return x
+}
+
+func FinishEmailConfirmLinkResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
+ builder.Finish(offset)
+}
+
+func GetSizePrefixedRootAsEmailConfirmLinkResult(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmLinkResult {
+ n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
+ x := &EmailConfirmLinkResult{}
+ x.Init(buf, n+offset+flatbuffers.SizeUint32)
+ return x
+}
+
+func FinishSizePrefixedEmailConfirmLinkResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
+ builder.FinishSizePrefixed(offset)
+}
+
+func (rcv *EmailConfirmLinkResult) Init(buf []byte, i flatbuffers.UOffsetT) {
+ rcv._tab.Bytes = buf
+ rcv._tab.Pos = i
+}
+
+func (rcv *EmailConfirmLinkResult) Table() flatbuffers.Table {
+ return rcv._tab
+}
+
+func (rcv *EmailConfirmLinkResult) Purpose() []byte {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
+ if o != 0 {
+ return rcv._tab.ByteVector(o + rcv._tab.Pos)
+ }
+ return nil
+}
+
+func (rcv *EmailConfirmLinkResult) Status() []byte {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
+ if o != 0 {
+ return rcv._tab.ByteVector(o + rcv._tab.Pos)
+ }
+ return nil
+}
+
+func (rcv *EmailConfirmLinkResult) Session(obj *Session) *Session {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
+ if o != 0 {
+ x := rcv._tab.Indirect(o + rcv._tab.Pos)
+ if obj == nil {
+ obj = new(Session)
+ }
+ obj.Init(rcv._tab.Bytes, x)
+ return obj
+ }
+ return nil
+}
+
+func EmailConfirmLinkResultStart(builder *flatbuffers.Builder) {
+ builder.StartObject(3)
+}
+func EmailConfirmLinkResultAddPurpose(builder *flatbuffers.Builder, purpose flatbuffers.UOffsetT) {
+ builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(purpose), 0)
+}
+func EmailConfirmLinkResultAddStatus(builder *flatbuffers.Builder, status flatbuffers.UOffsetT) {
+ builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(status), 0)
+}
+func EmailConfirmLinkResultAddSession(builder *flatbuffers.Builder, session flatbuffers.UOffsetT) {
+ builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(session), 0)
+}
+func EmailConfirmLinkResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
+ return builder.EndObject()
+}
diff --git a/pkg/fbs/scrabblefb/EmailRequestRequest.go b/pkg/fbs/scrabblefb/EmailRequestRequest.go
index 567b8ba..9f95230 100644
--- a/pkg/fbs/scrabblefb/EmailRequestRequest.go
+++ b/pkg/fbs/scrabblefb/EmailRequestRequest.go
@@ -57,8 +57,16 @@ func (rcv *EmailRequestRequest) BrowserTz() []byte {
return nil
}
+func (rcv *EmailRequestRequest) Language() []byte {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
+ if o != 0 {
+ return rcv._tab.ByteVector(o + rcv._tab.Pos)
+ }
+ return nil
+}
+
func EmailRequestRequestStart(builder *flatbuffers.Builder) {
- builder.StartObject(2)
+ builder.StartObject(3)
}
func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
@@ -66,6 +74,9 @@ func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers
func EmailRequestRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
}
+func EmailRequestRequestAddLanguage(builder *flatbuffers.Builder, language flatbuffers.UOffsetT) {
+ builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(language), 0)
+}
func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
index e337352..215f47b 100644
--- a/ui/src/App.svelte
+++ b/ui/src/App.svelte
@@ -15,6 +15,7 @@
import NewGame from './screens/NewGame.svelte';
import SettingsHub from './screens/SettingsHub.svelte';
import Stats from './screens/Stats.svelte';
+ import Confirm from './screens/Confirm.svelte';
import Game from './game/Game.svelte';
import CommsHub from './game/CommsHub.svelte';
import Feedback from './screens/Feedback.svelte';
@@ -110,6 +111,8 @@
{:else if router.route.name === 'stats'}
+ {:else if router.route.name === 'confirm'}
+
{:else}
{/if}
diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts
index a5aece4..f8b81e2 100644
--- a/ui/src/gen/fbs/scrabblefb.ts
+++ b/ui/src/gen/fbs/scrabblefb.ts
@@ -17,6 +17,8 @@ export { ComplaintRequest } from './scrabblefb/complaint-request.js';
export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js';
export { DraftRequest } from './scrabblefb/draft-request.js';
export { DraftView } from './scrabblefb/draft-view.js';
+export { EmailConfirmLinkRequest } from './scrabblefb/email-confirm-link-request.js';
+export { EmailConfirmLinkResult } from './scrabblefb/email-confirm-link-result.js';
export { EmailLoginRequest } from './scrabblefb/email-login-request.js';
export { EmailRequestRequest } from './scrabblefb/email-request-request.js';
export { EnqueueRequest } from './scrabblefb/enqueue-request.js';
diff --git a/ui/src/gen/fbs/scrabblefb/email-confirm-link-request.ts b/ui/src/gen/fbs/scrabblefb/email-confirm-link-request.ts
new file mode 100644
index 0000000..e6d8274
--- /dev/null
+++ b/ui/src/gen/fbs/scrabblefb/email-confirm-link-request.ts
@@ -0,0 +1,48 @@
+// automatically generated by the FlatBuffers compiler, do not modify
+
+import * as flatbuffers from 'flatbuffers';
+
+export class EmailConfirmLinkRequest {
+ bb: flatbuffers.ByteBuffer|null = null;
+ bb_pos = 0;
+ __init(i:number, bb:flatbuffers.ByteBuffer):EmailConfirmLinkRequest {
+ this.bb_pos = i;
+ this.bb = bb;
+ return this;
+}
+
+static getRootAsEmailConfirmLinkRequest(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmLinkRequest):EmailConfirmLinkRequest {
+ return (obj || new EmailConfirmLinkRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
+}
+
+static getSizePrefixedRootAsEmailConfirmLinkRequest(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmLinkRequest):EmailConfirmLinkRequest {
+ bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
+ return (obj || new EmailConfirmLinkRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
+}
+
+token():string|null
+token(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
+token(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 startEmailConfirmLinkRequest(builder:flatbuffers.Builder) {
+ builder.startObject(1);
+}
+
+static addToken(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset) {
+ builder.addFieldOffset(0, tokenOffset, 0);
+}
+
+static endEmailConfirmLinkRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
+ const offset = builder.endObject();
+ return offset;
+}
+
+static createEmailConfirmLinkRequest(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset):flatbuffers.Offset {
+ EmailConfirmLinkRequest.startEmailConfirmLinkRequest(builder);
+ EmailConfirmLinkRequest.addToken(builder, tokenOffset);
+ return EmailConfirmLinkRequest.endEmailConfirmLinkRequest(builder);
+}
+}
diff --git a/ui/src/gen/fbs/scrabblefb/email-confirm-link-result.ts b/ui/src/gen/fbs/scrabblefb/email-confirm-link-result.ts
new file mode 100644
index 0000000..29551ab
--- /dev/null
+++ b/ui/src/gen/fbs/scrabblefb/email-confirm-link-result.ts
@@ -0,0 +1,66 @@
+// automatically generated by the FlatBuffers compiler, do not modify
+
+import * as flatbuffers from 'flatbuffers';
+
+import { Session } from '../scrabblefb/session.js';
+
+
+export class EmailConfirmLinkResult {
+ bb: flatbuffers.ByteBuffer|null = null;
+ bb_pos = 0;
+ __init(i:number, bb:flatbuffers.ByteBuffer):EmailConfirmLinkResult {
+ this.bb_pos = i;
+ this.bb = bb;
+ return this;
+}
+
+static getRootAsEmailConfirmLinkResult(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmLinkResult):EmailConfirmLinkResult {
+ return (obj || new EmailConfirmLinkResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
+}
+
+static getSizePrefixedRootAsEmailConfirmLinkResult(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmLinkResult):EmailConfirmLinkResult {
+ bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
+ return (obj || new EmailConfirmLinkResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
+}
+
+purpose():string|null
+purpose(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
+purpose(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;
+}
+
+status():string|null
+status(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
+status(optionalEncoding?:any):string|Uint8Array|null {
+ const offset = this.bb!.__offset(this.bb_pos, 6);
+ return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
+}
+
+session(obj?:Session):Session|null {
+ const offset = this.bb!.__offset(this.bb_pos, 8);
+ return offset ? (obj || new Session()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
+}
+
+static startEmailConfirmLinkResult(builder:flatbuffers.Builder) {
+ builder.startObject(3);
+}
+
+static addPurpose(builder:flatbuffers.Builder, purposeOffset:flatbuffers.Offset) {
+ builder.addFieldOffset(0, purposeOffset, 0);
+}
+
+static addStatus(builder:flatbuffers.Builder, statusOffset:flatbuffers.Offset) {
+ builder.addFieldOffset(1, statusOffset, 0);
+}
+
+static addSession(builder:flatbuffers.Builder, sessionOffset:flatbuffers.Offset) {
+ builder.addFieldOffset(2, sessionOffset, 0);
+}
+
+static endEmailConfirmLinkResult(builder:flatbuffers.Builder):flatbuffers.Offset {
+ const offset = builder.endObject();
+ return offset;
+}
+
+}
diff --git a/ui/src/gen/fbs/scrabblefb/email-request-request.ts b/ui/src/gen/fbs/scrabblefb/email-request-request.ts
index 6ecbb46..47212e5 100644
--- a/ui/src/gen/fbs/scrabblefb/email-request-request.ts
+++ b/ui/src/gen/fbs/scrabblefb/email-request-request.ts
@@ -34,8 +34,15 @@ browserTz(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
+language():string|null
+language(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
+language(optionalEncoding?:any):string|Uint8Array|null {
+ const offset = this.bb!.__offset(this.bb_pos, 8);
+ return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
+}
+
static startEmailRequestRequest(builder:flatbuffers.Builder) {
- builder.startObject(2);
+ builder.startObject(3);
}
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
@@ -46,15 +53,20 @@ static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Off
builder.addFieldOffset(1, browserTzOffset, 0);
}
+static addLanguage(builder:flatbuffers.Builder, languageOffset:flatbuffers.Offset) {
+ builder.addFieldOffset(2, languageOffset, 0);
+}
+
static endEmailRequestRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
-static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
+static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, languageOffset:flatbuffers.Offset):flatbuffers.Offset {
EmailRequestRequest.startEmailRequestRequest(builder);
EmailRequestRequest.addEmail(builder, emailOffset);
EmailRequestRequest.addBrowserTz(builder, browserTzOffset);
+ EmailRequestRequest.addLanguage(builder, languageOffset);
return EmailRequestRequest.endEmailRequestRequest(builder);
}
}
diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts
index 80deed6..2cce2a4 100644
--- a/ui/src/lib/app.svelte.ts
+++ b/ui/src/lib/app.svelte.ts
@@ -409,6 +409,11 @@ function openStream(): void {
if (e.sub === 'banner') {
void refreshProfile();
}
+ // The viewer's own profile changed out of band (an email confirmed via the
+ // one-tap deeplink opened in another browser): re-fetch it.
+ if (e.sub === 'profile') {
+ void refreshProfile();
+ }
void refreshNotifications();
}
},
@@ -738,7 +743,7 @@ export async function bootstrap(): Promise {
if (saved) {
await adoptSession(saved);
if (router.route.name === 'login') navigate('/');
- } else if (router.route.name !== 'login') {
+ } else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
navigate('/login');
}
app.ready = true;
@@ -915,7 +920,7 @@ export async function loginGuest(): Promise {
export async function requestEmailCode(email: string): Promise {
try {
- await gateway.authEmailRequest(email);
+ await gateway.authEmailRequest(email, app.locale);
return true;
} catch (err) {
handleError(err);
@@ -933,6 +938,20 @@ export async function loginEmail(email: string, code: string): Promise {
}
}
+// confirmDeeplink verifies a one-tap email deeplink token opened from a confirmation
+// email. A login adopts the minted session and enters the app; a link reports whether
+// it confirmed or needs the interactive merge. It throws on an invalid/expired token,
+// which the confirm screen surfaces. This browser need not be signed in.
+export async function confirmDeeplink(token: string): Promise<'login' | 'linked' | 'merge_required'> {
+ const r = await gateway.confirmEmailLink(token);
+ if (r.purpose === 'login' && r.session) {
+ await adoptSession(r.session);
+ navigate('/');
+ return 'login';
+ }
+ return r.status === 'merge_required' ? 'merge_required' : 'linked';
+}
+
export async function logout(): Promise {
closeStream();
resetConnection();
diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts
index b91bcb2..b1767ff 100644
--- a/ui/src/lib/client.ts
+++ b/ui/src/lib/client.ts
@@ -20,6 +20,7 @@ import type {
HintResult,
Invitation,
InvitationSettings,
+ ConfirmLinkResult,
LinkResult,
MatchResult,
MoveResult,
@@ -64,8 +65,11 @@ export interface GatewayClient {
* cosmetic seed for a brand-new account). */
authVK(params: string, displayName: string): Promise;
authGuest(locale?: string): Promise;
- authEmailRequest(email: string): Promise;
+ authEmailRequest(email: string, language: string): Promise;
authEmailLogin(email: string, code: string): Promise;
+ /** Confirm a one-tap email deeplink token: a login returns a session to adopt; a
+ * link returns a status ('confirmed' | 'merge_required'). */
+ confirmEmailLink(token: string): Promise;
// --- profile / lists ---
profileGet(): Promise;
diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts
index 429fcdd..4e1b1ab 100644
--- a/ui/src/lib/codec.test.ts
+++ b/ui/src/lib/codec.test.ts
@@ -21,6 +21,7 @@ import {
decodeStats,
encodeCheckWord,
encodeEmailRequest,
+ encodeEmailConfirmLink,
encodeFeedbackSubmit,
encodeDraftSave,
encodeEnqueue,
@@ -117,10 +118,16 @@ describe('codec', () => {
expect(guest.browserTz()).toBe('-05:30');
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
- new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00')),
+ new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en')),
);
expect(email.email()).toBe('a@example.com');
expect(email.browserTz()).toBe('+00:00');
+ expect(email.language()).toBe('en');
+
+ const confirm = fb.EmailConfirmLinkRequest.getRootAsEmailConfirmLinkRequest(
+ new ByteBuffer(encodeEmailConfirmLink('tok-abc')),
+ );
+ expect(confirm.token()).toBe('tok-abc');
const vk = fb.VKLoginRequest.getRootAsVKLoginRequest(
new ByteBuffer(encodeVKLogin('vk_user_id=494075&vk_ts=1&sign=abc', '+03:00', 'Иван Петров')),
diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts
index 4665bd0..e617ee2 100644
--- a/ui/src/lib/codec.ts
+++ b/ui/src/lib/codec.ts
@@ -31,6 +31,7 @@ import type {
Invitation,
InvitationInvitee,
InvitationSettings,
+ ConfirmLinkResult,
LinkResult,
MatchResult,
MoveRecord,
@@ -212,13 +213,15 @@ export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
}
-export function encodeEmailRequest(email: string, browserTz: string): Uint8Array {
+export function encodeEmailRequest(email: string, browserTz: string, language: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
const tz = b.createString(browserTz);
+ const l = b.createString(language);
fb.EmailRequestRequest.startEmailRequestRequest(b);
fb.EmailRequestRequest.addEmail(b, e);
fb.EmailRequestRequest.addBrowserTz(b, tz);
+ fb.EmailRequestRequest.addLanguage(b, l);
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
}
@@ -232,6 +235,14 @@ export function encodeEmailLogin(email: string, code: string): Uint8Array {
return finish(b, fb.EmailLoginRequest.endEmailLoginRequest(b));
}
+export function encodeEmailConfirmLink(token: string): Uint8Array {
+ const b = new Builder(128);
+ const t = b.createString(token);
+ fb.EmailConfirmLinkRequest.startEmailConfirmLinkRequest(b);
+ fb.EmailConfirmLinkRequest.addToken(b, t);
+ return finish(b, fb.EmailConfirmLinkRequest.endEmailConfirmLinkRequest(b));
+}
+
// --- response decoders ---
function s(v: string | null): string {
@@ -730,6 +741,16 @@ export function decodeLinkResult(buf: Uint8Array): LinkResult {
};
}
+export function decodeConfirmLinkResult(buf: Uint8Array): ConfirmLinkResult {
+ const r = fb.EmailConfirmLinkResult.getRootAsEmailConfirmLinkResult(new ByteBuffer(buf));
+ const sess = r.session();
+ return {
+ purpose: (s(r.purpose()) || 'link') as ConfirmLinkResult['purpose'],
+ status: (s(r.status()) || 'confirmed') as ConfirmLinkResult['status'],
+ session: sess ? sessionFromTable(sess) : null,
+ };
+}
+
// --- social decoders ---
function decodeAccountRef(r: fb.AccountRef): AccountRef {
diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts
index 4f6cbc0..0223ffc 100644
--- a/ui/src/lib/i18n/en.ts
+++ b/ui/src/lib/i18n/en.ts
@@ -4,6 +4,7 @@
export const en = {
'app.title': 'Scrabble',
+ 'app.brand': 'Erudit',
'dict.loading': 'Loading…',
'connection.connecting': 'Connecting…',
@@ -36,6 +37,11 @@ export const en = {
'login.sendCode': 'Send code',
'login.codePlaceholder': '6-digit code',
'login.signIn': 'Sign in',
+ 'confirm.busy': 'Confirming your email…',
+ 'confirm.linked': 'Email confirmed.',
+ 'confirm.merge': 'Almost done — finish the merge in the app.',
+ 'confirm.close': 'You can close this window and return to the game.',
+ 'confirm.expired': 'This link is invalid or has expired. Request a new code.',
'login.codeSent': 'We sent a code to {email}.',
'lobby.activeGames': 'Active games',
diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts
index dafa2df..068a61f 100644
--- a/ui/src/lib/i18n/ru.ts
+++ b/ui/src/lib/i18n/ru.ts
@@ -5,6 +5,7 @@ import type { MessageKey } from './en';
export const ru: Record = {
'app.title': 'Scrabble',
+ 'app.brand': 'Эрудит',
'dict.loading': 'Загрузка…',
'connection.connecting': 'Подключение…',
@@ -37,6 +38,11 @@ export const ru: Record = {
'login.sendCode': 'Отправить код',
'login.codePlaceholder': 'Код из 6 цифр',
'login.signIn': 'Войти',
+ 'confirm.busy': 'Подтверждаем почту…',
+ 'confirm.linked': 'Почта подтверждена.',
+ 'confirm.merge': 'Почти готово — завершите объединение в приложении.',
+ 'confirm.close': 'Можно закрыть это окно и вернуться в игру.',
+ 'confirm.expired': 'Ссылка недействительна или истекла. Запросите новый код.',
'login.codeSent': 'Мы отправили код на {email}.',
'lobby.activeGames': 'Активные игры',
diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts
index 1658c02..2aca5d8 100644
--- a/ui/src/lib/mock/client.ts
+++ b/ui/src/lib/mock/client.ts
@@ -27,6 +27,7 @@ import type {
HintResult,
Invitation,
InvitationSettings,
+ ConfirmLinkResult,
LinkResult,
MatchResult,
MoveResult,
@@ -149,7 +150,10 @@ export class MockGateway implements GatewayClient {
async authGuest(): Promise {
return { ...SESSION };
}
- async authEmailRequest(): Promise {}
+ async authEmailRequest(_email: string, _language: string): Promise {}
+ async confirmEmailLink(_token: string): Promise {
+ return { purpose: 'link', status: 'confirmed', session: null };
+ }
async authEmailLogin(): Promise {
return { ...SESSION, isGuest: false };
}
diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts
index 60fa825..28a4510 100644
--- a/ui/src/lib/model.ts
+++ b/ui/src/lib/model.ts
@@ -355,6 +355,15 @@ export interface LinkResult {
session: Session | null;
}
+// ConfirmLinkResult is the outcome of a one-tap deeplink confirmation. purpose is
+// 'login' (session carries the minted credential to sign in) or 'link' (status is
+// 'confirmed' or 'merge_required' — the app finishes any merge from its manual flow).
+export interface ConfirmLinkResult {
+ purpose: 'login' | 'link';
+ status: 'confirmed' | 'merge_required';
+ session: Session | null;
+}
+
export interface MatchResult {
matched: boolean;
game?: GameView;
diff --git a/ui/src/lib/routeparse.ts b/ui/src/lib/routeparse.ts
index 434c902..b91191d 100644
--- a/ui/src/lib/routeparse.ts
+++ b/ui/src/lib/routeparse.ts
@@ -15,6 +15,7 @@ export type RouteName =
| 'friends'
| 'feedback'
| 'stats'
+ | 'confirm'
| 'notfound';
export interface Route {
@@ -58,6 +59,11 @@ export function parse(hash: string): Route {
return { name: 'feedback', params: {} };
case 'stats':
return { name: 'stats', params: {} };
+ case 'confirm':
+ // The one-tap email deeplink: /confirm/. The token is the confirmation
+ // authorization; the screen POSTs it (prefetch-safe), so a mail scanner's GET
+ // does not consume it.
+ return { name: 'confirm', params: { token: seg[1] ?? '' } };
default:
return { name: 'notfound', params: {} };
}
diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts
index cc06486..62b07a6 100644
--- a/ui/src/lib/transport.ts
+++ b/ui/src/lib/transport.ts
@@ -101,8 +101,11 @@ export function createTransport(baseUrl: string): GatewayClient {
async authGuest(locale) {
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
},
- async authEmailRequest(email) {
- await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset()));
+ async authEmailRequest(email, language) {
+ await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language));
+ },
+ async confirmEmailLink(token) {
+ return codec.decodeConfirmLinkResult(await exec('auth.email.confirm_link', codec.encodeEmailConfirmLink(token)));
},
async authEmailLogin(email, code) {
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code)));
diff --git a/ui/src/screens/Confirm.svelte b/ui/src/screens/Confirm.svelte
new file mode 100644
index 0000000..bc64a97
--- /dev/null
+++ b/ui/src/screens/Confirm.svelte
@@ -0,0 +1,84 @@
+
+
+
+
+ {#if stage === 'busy'}
+
{t('confirm.busy')}
+ {:else if stage === 'error'}
+
{t('confirm.expired')}
+ {:else}
+
{t('app.brand')}
+
{stage === 'merge' ? t('confirm.merge') : t('confirm.linked')}
+
{t('confirm.close')}
+ {/if}
+
+
+
+