From d5b4bba018cea849697b276e3716cc0acd8b88dc Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 04:07:10 +0200 Subject: [PATCH 01/10] feat(account): restore the confirm deeplink token (domain layer) Re-apply the deeplink backend deferred out of PR1a: migration 00006 adds email_confirmations.purpose + link_token_hash (hand-edited jet), each issued code now carries an opaque 256-bit token (only its SHA-256 stored), and ConfirmByToken resolves a token to a login (confirm + clear guest) or a link (attach when free, signal merge when owned elsewhere). issueCode now embeds the /app/#/confirm/ link in the email. The confirm endpoint, gateway RPC and SPA route follow. --- backend/internal/account/email.go | 155 +++++++++++++++++- .../jet/backend/model/email_confirmations.go | 2 + .../jet/backend/table/email_confirmations.go | 12 +- .../00006_email_confirm_deeplink.sql | 27 +++ 4 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 backend/internal/postgres/migrations/00006_email_confirm_deeplink.sql diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index e4df71f..23c87b3 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), s.baseURL, locale) if err != nil { return err } @@ -111,6 +122,15 @@ 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, or "" when no +// public base URL is configured. +func (s *EmailService) confirmURL(token string) string { + if s.baseURL == "" { + return "" + } + return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token +} + // 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 +261,66 @@ 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 +} + +// 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 + } + 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 +351,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 +368,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 +402,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 +543,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/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; -- 2.52.0 From 25d80bc31ddf28a0a09a1698069a056252aa75b5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 04:13:48 +0200 Subject: [PATCH 02/10] feat(server): confirm-link endpoint for the one-tap deeplink Add POST /internal/sessions/email/confirm-link: it verifies a deeplink token via ConfirmByToken and, for a login, mints a session (the deeplink page signs in with it); for a link, attaches the confirmed email and reports "confirmed" or "merge_required" (the app drives the interactive merge). The token, not a request session, is the authorization. Add LinkConfirmation.IsLogin() and integration tests for the login, link and merge branches (the token is read from the mailed link). The gateway RPC, live event and SPA route follow. --- backend/internal/account/email.go | 4 + backend/internal/inttest/email_test.go | 95 ++++++++++++++++++++++++ backend/internal/server/handlers.go | 1 + backend/internal/server/handlers_auth.go | 47 ++++++++++++ 4 files changed, 147 insertions(+) diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index 23c87b3..ecfc967 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -274,6 +274,10 @@ type LinkConfirmation struct { 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 diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go index 2e64761..8937057 100644 --- a/backend/internal/inttest/email_test.go +++ b/backend/internal/inttest/email_test.go @@ -278,3 +278,98 @@ 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) + } +} 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_auth.go b/backend/internal/server/handlers_auth.go index a9b556c..e13138f 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -212,6 +212,53 @@ 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 + } + status := "confirmed" + if res.NeedsMerge { + status = "merge_required" + } + c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: status}) +} + // tokenRequest carries an opaque session token. type tokenRequest struct { Token string `json:"token"` -- 2.52.0 From 762155a55e07a45ac90d037f7cd71b9d0e318bf5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 04:22:09 +0200 Subject: [PATCH 03/10] feat(gateway): confirmEmailLink RPC + language on the email request + profile event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the confirm-link edge method (auth.email.confirm_link): a new EmailConfirmLinkRequest/Result fbs table, the transcode const + handler + encoder, and the backend-client call to the existing /sessions/email/confirm-link endpoint — it rides Execute under the existing service prefix, so no proto/Caddy change. Add a language field to EmailRequestRequest and forward it (the backend already seeds it). Add the NotifyProfile sub-kind + notify.ProfileChanged, published by the confirm-link handler on a successful link so an in-app session re-fetches its profile when the email was confirmed in another browser. Regenerated fbs bindings (Go + TS). --- backend/internal/notify/events.go | 7 ++ backend/internal/notify/notify.go | 4 + backend/internal/server/handlers_auth.go | 10 ++- gateway/internal/backendclient/api.go | 21 ++++- gateway/internal/transcode/encode.go | 29 +++++++ gateway/internal/transcode/transcode.go | 77 +++++++++------- pkg/fbs/scrabble.fbs | 19 +++- pkg/fbs/scrabblefb/EmailConfirmLinkRequest.go | 60 +++++++++++++ pkg/fbs/scrabblefb/EmailConfirmLinkResult.go | 87 +++++++++++++++++++ pkg/fbs/scrabblefb/EmailRequestRequest.go | 13 ++- ui/src/gen/fbs/scrabblefb.ts | 2 + .../scrabblefb/email-confirm-link-request.ts | 48 ++++++++++ .../scrabblefb/email-confirm-link-result.ts | 66 ++++++++++++++ .../fbs/scrabblefb/email-request-request.ts | 16 +++- 14 files changed, 418 insertions(+), 41 deletions(-) create mode 100644 pkg/fbs/scrabblefb/EmailConfirmLinkRequest.go create mode 100644 pkg/fbs/scrabblefb/EmailConfirmLinkResult.go create mode 100644 ui/src/gen/fbs/scrabblefb/email-confirm-link-request.ts create mode 100644 ui/src/gen/fbs/scrabblefb/email-confirm-link-result.ts 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/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index e13138f..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 @@ -252,11 +253,14 @@ func (s *Server) handleEmailConfirmLink(c *gin.Context) { c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "login", Status: "confirmed", Session: &sess}) return } - status := "confirmed" if res.NeedsMerge { - status = "merge_required" + c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: "merge_required"}) + return } - c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: status}) + // 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. 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/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); } } -- 2.52.0 From 409462fc09ebb07ef49b91b4c8d53d66c574dbca Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 04:30:16 +0200 Subject: [PATCH 04/10] feat(ui): one-tap confirm deeplink screen + client language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the /confirm/ SPA route and Confirm screen: a prefetch-safe button POSTs the token via a new confirmEmailLink RPC (client/transport/mock/codec + ConfirmLinkResult model). A login adopts the minted session and enters the app; a link shows confirmed / merge-in-the-app; an invalid or expired token asks for a new code. Exempt /confirm from the no-session /login redirect. Forward the client locale on the email request (authEmailRequest gains language → app.locale) so a fresh web login email is localised. Handle the new 'profile' live-event sub-kind by re-fetching the profile, so a link confirmed in another browser reflects in-app at once. i18n en+ru. --- ui/src/App.svelte | 3 ++ ui/src/lib/app.svelte.ts | 23 ++++++++- ui/src/lib/client.ts | 6 ++- ui/src/lib/codec.test.ts | 2 +- ui/src/lib/codec.ts | 23 ++++++++- ui/src/lib/i18n/en.ts | 6 +++ ui/src/lib/i18n/ru.ts | 6 +++ ui/src/lib/mock/client.ts | 6 ++- ui/src/lib/model.ts | 9 ++++ ui/src/lib/routeparse.ts | 6 +++ ui/src/lib/transport.ts | 7 ++- ui/src/screens/Confirm.svelte | 95 +++++++++++++++++++++++++++++++++++ 12 files changed, 184 insertions(+), 8 deletions(-) create mode 100644 ui/src/screens/Confirm.svelte 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/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..21b37ae 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -117,7 +117,7 @@ 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'); 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..10f4634 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -36,6 +36,12 @@ export const en = { 'login.sendCode': 'Send code', 'login.codePlaceholder': '6-digit code', 'login.signIn': 'Sign in', + 'confirm.prompt': 'Confirm your email to finish.', + 'confirm.action': 'Confirm', + '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..9cd928c 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -37,6 +37,12 @@ export const ru: Record = { 'login.sendCode': 'Отправить код', 'login.codePlaceholder': 'Код из 6 цифр', 'login.signIn': 'Войти', + 'confirm.prompt': 'Подтвердите почту, чтобы завершить.', + 'confirm.action': 'Подтвердить', + '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..5af9f62 --- /dev/null +++ b/ui/src/screens/Confirm.svelte @@ -0,0 +1,95 @@ + + +
+
+
{t('app.title')}
+ {#if stage === 'idle' || stage === 'busy'} +

{t('confirm.prompt')}

+ + {:else if stage === 'linked'} +

{t('confirm.linked')}

+

{t('confirm.close')}

+ {:else if stage === 'merge'} +

{t('confirm.merge')}

+

{t('confirm.close')}

+ {:else} +

{t('confirm.expired')}

+ {/if} +
+
+ + -- 2.52.0 From 65f2c87a746439f259df6a6a464ac04112099ea4 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 04:33:27 +0200 Subject: [PATCH 05/10] docs+test(email): document the confirm deeplink + wire-contract tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the one-tap confirm deeplink in ARCHITECTURE (§4 the login magic-link / link confirm+profile-refresh, prefetch-safe token) and the new notify 'profile' sub-kind (§10), and add the one-tap link to the FUNCTIONAL email story (+ru). Add codec round-trip assertions for the EmailRequest language field and encodeEmailConfirmLink (the mock e2e bypasses the codec). --- docs/ARCHITECTURE.md | 13 +++++++++++-- docs/FUNCTIONAL.md | 5 ++++- docs/FUNCTIONAL_ru.md | 5 ++++- ui/src/lib/codec.test.ts | 7 +++++++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 182212f..c326ad2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -239,7 +239,13 @@ 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 page is prefetch-safe — it confirms only on a button + press, so a mail scanner's GET does not consume the single-use token. 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 +939,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..abafd3c 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -66,7 +66,10 @@ 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 never triggers it (it acts only on a button press). 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..690ddfb 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -71,7 +71,10 @@ launch-параметрам VK (их проверяет gateway), и при пе запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная, неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап -не завалили почтовый ящик. +не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает +адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в +приложении, а почтовый сканер, который просто загружает ссылку, её не срабатывает (действие +только по нажатию кнопки). Telegram держит **единого бота**: все игроки пользуются одним и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 21b37ae..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, @@ -121,6 +122,12 @@ describe('codec', () => { ); 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', 'Иван Петров')), -- 2.52.0 From 5804f7266e600780c5c405c49b560dfd6bf46817 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 05:10:22 +0200 Subject: [PATCH 06/10] fix(account): seed the email account display name from the local part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An email account was provisioned with no display name (unlike Telegram/VK, which seed one), so an email login showed an empty name. Seed it from the email's local part (before '@', trimmed and capped to the column width) on first contact; the user can rename it later. Only new accounts are seeded — an existing account's name is never overwritten. --- backend/internal/account/account.go | 18 ++++++++++++++++-- backend/internal/inttest/email_test.go | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) 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/inttest/email_test.go b/backend/internal/inttest/email_test.go index 8937057..ea3940b 100644 --- a/backend/internal/inttest/email_test.go +++ b/backend/internal/inttest/email_test.go @@ -373,3 +373,24 @@ func TestConfirmByTokenLinkMerge(t *testing.T) { 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) + } +} -- 2.52.0 From 77a18a3cc1d266997f8054632988c474302b6a07 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 05:18:24 +0200 Subject: [PATCH 07/10] fix(account): clear the guest flag on a deeplink email link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConfirmByToken attached the confirmed email to the account but, on the link path, skipped ClearGuest — so a guest who bound an email via the one-tap deeplink stayed a guest (the code-based flow clears it in the link service). Clear the flag on a free link too, promoting the guest to a durable account; the profile live event then refreshes the open session. Integration test added. --- backend/internal/account/email.go | 5 +++++ backend/internal/inttest/email_test.go | 29 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index ecfc967..4cfdf9a 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -319,6 +319,11 @@ func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkCo 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) diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go index ea3940b..667edd1 100644 --- a/backend/internal/inttest/email_test.go +++ b/backend/internal/inttest/email_test.go @@ -394,3 +394,32 @@ func TestEmailAccountSeedsDisplayName(t *testing.T) { 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") + } +} -- 2.52.0 From 1dca6741f13abaf768dff1c20b1511834b4eb21c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 05:18:24 +0200 Subject: [PATCH 08/10] feat(ui): auto-confirm the email deeplink on load Drop the confirm button: the /confirm screen confirms the token as soon as it loads. 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. Update the ARCHITECTURE/FUNCTIONAL wording accordingly and swap the confirm.prompt/action strings for confirm.busy (en+ru). --- docs/ARCHITECTURE.md | 5 +++-- docs/FUNCTIONAL.md | 3 ++- docs/FUNCTIONAL_ru.md | 4 ++-- ui/src/lib/i18n/en.ts | 3 +-- ui/src/lib/i18n/ru.ts | 3 +-- ui/src/screens/Confirm.svelte | 35 +++++++++++------------------------ 6 files changed, 20 insertions(+), 33 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c326ad2..223bc07 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -244,8 +244,9 @@ arrive from a platform rather than completing a mandatory registration). 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 page is prefetch-safe — it confirms only on a button - press, so a mail scanner's GET does not consume the single-use token. An + 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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index abafd3c..ec82aef 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -69,7 +69,8 @@ freed. Sends are rate-limited (a short cooldown between codes and a small hourly 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 never triggers it (it acts only on a button press). +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 690ddfb..410a46a 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -73,8 +73,8 @@ launch-параметрам VK (их проверяет gateway), и при пе пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в -приложении, а почтовый сканер, который просто загружает ссылку, её не срабатывает (действие -только по нажатию кнопки). +приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется — +он едет в URL-фрагменте, который не уходит на сервер. Telegram держит **единого бота**: все игроки пользуются одним и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 10f4634..6a7cce1 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -36,8 +36,7 @@ export const en = { 'login.sendCode': 'Send code', 'login.codePlaceholder': '6-digit code', 'login.signIn': 'Sign in', - 'confirm.prompt': 'Confirm your email to finish.', - 'confirm.action': 'Confirm', + '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.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 9cd928c..063b652 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -37,8 +37,7 @@ export const ru: Record = { 'login.sendCode': 'Отправить код', 'login.codePlaceholder': 'Код из 6 цифр', 'login.signIn': 'Войти', - 'confirm.prompt': 'Подтвердите почту, чтобы завершить.', - 'confirm.action': 'Подтвердить', + 'confirm.busy': 'Подтверждаем почту…', 'confirm.linked': 'Почта подтверждена.', 'confirm.merge': 'Почти готово — завершите объединение в приложении.', 'confirm.close': 'Можно закрыть это окно и вернуться в игру.', diff --git a/ui/src/screens/Confirm.svelte b/ui/src/screens/Confirm.svelte index 5af9f62..d97b31a 100644 --- a/ui/src/screens/Confirm.svelte +++ b/ui/src/screens/Confirm.svelte @@ -1,37 +1,35 @@
{t('app.title')}
- {#if stage === 'idle' || stage === 'busy'} -

{t('confirm.prompt')}

- + {#if stage === 'busy'} +

{t('confirm.busy')}

{:else if stage === 'linked'}

{t('confirm.linked')}

{t('confirm.close')}

@@ -81,15 +79,4 @@ .err { color: var(--danger, #c0392b); } - button { - padding: 12px; - border-radius: var(--radius-sm); - border: 1px solid var(--accent); - font-weight: 600; - background: var(--accent); - color: var(--accent-text); - } - button:disabled { - opacity: 0.5; - } -- 2.52.0 From 356bf1a5baf25d65b1d1e6e2baf05e88ac84c3fa Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 05:30:30 +0200 Subject: [PATCH 09/10] feat(admin): search users by email + erase a bound email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an exact (strict) email filter to the /users list (UserFilter.EmailExact → a kind='email' identity match) with a search input, and an 'Erase email' action on the user card that deletes the bound email identity and its pending confirmations, freeing the address. It refuses to remove the account's only identity (ErrLastIdentity), which would leave it unreachable. Integration tests for both. --- backend/internal/account/link.go | 47 +++++++++++ backend/internal/account/userlist.go | 8 +- .../templates/pages/user_detail.gohtml | 5 ++ .../adminconsole/templates/pages/users.gohtml | 1 + backend/internal/adminconsole/views.go | 5 +- backend/internal/inttest/adminemail_test.go | 83 +++++++++++++++++++ .../internal/server/handlers_admin_console.go | 28 +++++++ 7 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 backend/internal/inttest/adminemail_test.go 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/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. -- 2.52.0 From 54af644429104b2ebb72625bde620a8f31a52511 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 05:54:28 +0200 Subject: [PATCH 10/10] fix(ui): localise the confirm screen, drop the brand on the error state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session-less /confirm page defaulted to English, so it showed the English app title. Carry the recipient's language on the deeplink (?lang) and setLocale on load so the page matches the email. Show a localised brand wordmark (Эрудит / Erudit, matching the email) on the success state only; the invalid/expired state now shows just the message, no header. --- backend/internal/account/email.go | 11 ++++++----- ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/screens/Confirm.svelte | 30 ++++++++++++++++-------------- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index 4cfdf9a..64f7d53 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -114,7 +114,7 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email 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), s.baseURL, locale) + msg, err := renderConfirmationEmail(purpose, code, s.confirmURL(token, locale), s.baseURL, locale) if err != nil { return err } @@ -122,13 +122,14 @@ 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, or "" when no -// public base URL is configured. -func (s *EmailService) confirmURL(token string) string { +// 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 + return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token + "?lang=" + normalizeLocale(locale) } // accountLocale returns the account's preferred UI language for localising email, diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 6a7cce1..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…', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 063b652..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': 'Подключение…', diff --git a/ui/src/screens/Confirm.svelte b/ui/src/screens/Confirm.svelte index d97b31a..bc64a97 100644 --- a/ui/src/screens/Confirm.svelte +++ b/ui/src/screens/Confirm.svelte @@ -1,22 +1,27 @@