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}} +
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} +
+
+ +