feat(email): PWA login sends code only; drop admin link from alert emails
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s

Two email changes, per the owner:

1. Code-only login from an installed PWA. A login requested while running as a
   standalone PWA now omits the one-tap confirm link from the email — the link
   would open in a separate browser whose minted session cannot reach the PWA,
   stranding the login. The code is typed in the same window instead. The client
   sends a `pwa` flag (isStandalone) on the email-code request (a new FBS field,
   threaded through the gateway); the backend omits the deeplink when it is set,
   reusing the existing deletion-code link-omission path. Non-PWA browser logins
   keep the one-tap link. No polling, no migration.

2. Security: the operator alert digest no longer embeds the admin-console (/_gm)
   URL. An admin link must never travel in an email, where a mail provider could
   cache or index it; the operator opens the console directly.

Tests: an inttest asserting the PWA login email omits the link (and a browser one
keeps it); a codec round-trip of the new pwa field; the alert-digest test flipped
to guard the admin link is absent. Docs: ARCHITECTURE + FUNCTIONAL (+ru).
This commit is contained in:
Ilia Denisov
2026-07-05 22:13:21 +02:00
parent d19609f87d
commit 061366da5a
21 changed files with 139 additions and 57 deletions
+3 -6
View File
@@ -12,7 +12,6 @@ import (
"fmt"
"log"
"os/signal"
"strings"
"syscall"
"time"
@@ -216,11 +215,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// Operator alert emails on new feedback / word complaints, coalesced into one digest
// per interval. Inert unless a distinct admin sender and recipient are configured.
if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" {
consoleURL := ""
if cfg.PublicBaseURL != "" {
consoleURL = strings.TrimRight(cfg.PublicBaseURL, "/") + "/_gm"
}
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, consoleURL, logger)
// The alert digest carries no admin-console link on purpose — an admin URL must not
// travel in an email (a mail provider could cache or index it); see adminalert.New.
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, logger)
go alerts.Run(ctx, adminAlertInterval)
logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval))
}
+16 -11
View File
@@ -105,9 +105,10 @@ func (s *EmailService) allowSend(email string) bool {
// 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 {
// locale; purpose selects the email wording and what verifying does. omitLink drops the
// one-tap deeplink from the email (account deletion, or a login requested from an installed
// PWA). 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, omitLink bool) error {
code, codeHash, err := generateCode()
if err != nil {
return err
@@ -119,11 +120,12 @@ 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
}
// Account deletion is never a one-tap link (a prefetch or a stray click must not delete
// an account): the delete code is entered in the app only, so the email omits the
// deeplink and ConfirmByToken refuses a delete token.
// Omit the one-tap deeplink when asked: for a login from an installed PWA (the link would
// open in a separate browser whose minted session cannot reach the PWA), or for account
// deletion (a prefetch or stray click must not delete an account — the delete code is entered
// in the app only, and ConfirmByToken refuses a delete token).
deeplink := s.confirmURL(token, locale)
if purpose == purposeDelete {
if purpose == purposeDelete || omitLink {
deeplink = ""
}
msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale)
@@ -175,7 +177,7 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
}
return ErrEmailTaken
}
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
}
// ConfirmCode verifies code for accountID and email. On success it attaches a
@@ -221,8 +223,11 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
// the ordinary returning-user login. The code is mailed to the address, so only its
// real owner can complete the login. On first contact browserTZ (the client's
// detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI
// language. It returns the target account id for the subsequent LoginWithCode.
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string) (uuid.UUID, error) {
// language. When pwa is set (the request came from an installed PWA) the login email omits the
// one-tap confirm link — it would open in a separate browser, out of the PWA's reach — so the
// code is entered in the same window. It returns the target account id for the subsequent
// LoginWithCode.
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string, pwa bool) (uuid.UUID, error) {
addr, err := normalizeEmail(email)
if err != nil {
return uuid.UUID{}, err
@@ -234,7 +239,7 @@ func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, l
if err != nil {
return uuid.UUID{}, err
}
if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language); err != nil {
if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language, pwa); err != nil {
return uuid.UUID{}, err
}
return acc.ID, nil
+3 -3
View File
@@ -92,7 +92,7 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID,
if !s.allowSend(addr) {
return ErrTooManyRequests
}
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
}
// ConfirmLink verifies code for (accountID, email) and reports the address's
@@ -140,7 +140,7 @@ func (s *EmailService) RequestChangeCode(ctx context.Context, accountID uuid.UUI
if !s.allowSend(addr) {
return ErrTooManyRequests
}
return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID))
return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID), false)
}
// ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the
@@ -199,7 +199,7 @@ func (s *EmailService) RequestDeleteCode(ctx context.Context, accountID uuid.UUI
if !s.allowSend(addr) {
return ErrTooManyRequests
}
return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID))
return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID), false)
}
// VerifyDeleteCode verifies the account-deletion code against the account's own email and
+8 -9
View File
@@ -33,21 +33,21 @@ type Notifier struct {
complaints ComplaintCounter
from string
to string
consoleURL string
clock func() time.Time
log *zap.Logger
last time.Time
}
// New constructs a Notifier. from and to are the alert sender and recipient(s); consoleURL,
// when non-empty, is the admin-console link included in the email. log may be nil. The
// watermark starts at "now", so only items arriving after start-up are reported.
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to, consoleURL string, log *zap.Logger) *Notifier {
// New constructs a Notifier. from and to are the alert sender and recipient(s); log may be
// nil. The watermark starts at "now", so only items arriving after start-up are reported. The
// digest deliberately carries no admin-console link — an admin URL must never travel in an
// email, where a mail provider could cache or index it.
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to string, log *zap.Logger) *Notifier {
if log == nil {
log = zap.NewNop()
}
return &Notifier{
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to, consoleURL: consoleURL,
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to,
clock: func() time.Time { return time.Now().UTC() }, log: log, last: time.Now().UTC(),
}
}
@@ -103,10 +103,9 @@ func (n *Notifier) digest(fb, cp int) account.Message {
parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp))
}
summary := strings.Join(parts, ", ")
// No admin-console link in the body: an admin URL must never travel in an email (a mail
// provider could cache or index it). The operator opens the console directly.
text := summary + "."
if n.consoleURL != "" {
text += "\n\nOpen the admin console: " + n.consoleURL
}
return account.Message{
From: n.from,
To: n.to,
+6 -4
View File
@@ -28,7 +28,7 @@ func (m *recordingMailer) Send(_ context.Context, msg account.Message) error {
func TestNotifierSkipsWhenNothingNew(t *testing.T) {
mailer := &recordingMailer{}
n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", "", nil)
n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", nil)
n.tick(context.Background())
if len(mailer.sent) != 0 {
t.Fatalf("sent %d emails, want 0 when nothing is new", len(mailer.sent))
@@ -37,7 +37,7 @@ func TestNotifierSkipsWhenNothingNew(t *testing.T) {
func TestNotifierDigestsNewItems(t *testing.T) {
mailer := &recordingMailer{}
n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", "https://erudit-game.ru/_gm", nil)
n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", nil)
n.tick(context.Background())
if len(mailer.sent) != 1 {
t.Fatalf("sent %d emails, want 1 digest", len(mailer.sent))
@@ -49,7 +49,9 @@ func TestNotifierDigestsNewItems(t *testing.T) {
if !strings.Contains(msg.Subject, "2 new feedback") || !strings.Contains(msg.Subject, "1 new word complaint") {
t.Errorf("digest subject = %q, want the feedback + complaint counts", msg.Subject)
}
if !strings.Contains(msg.Text, "/_gm") {
t.Errorf("digest body = %q, want the console link", msg.Text)
// The digest must never carry an admin-console link — an admin URL in an email is a leak
// (mail providers cache/index it).
if strings.Contains(msg.Text, "/_gm") || strings.Contains(strings.ToLower(msg.Text), "admin console") {
t.Errorf("digest body = %q, must not carry an admin-console link", msg.Text)
}
}
+33 -5
View File
@@ -206,7 +206,7 @@ func TestEmailLoginFlow(t *testing.T) {
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
email := "login-" + uuid.NewString() + "@example.com"
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en")
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en", false)
if err != nil {
t.Fatalf("request login code: %v", err)
}
@@ -233,7 +233,7 @@ func TestEmailLoginFlow(t *testing.T) {
}
// A second login for the same email is the returning user: same account.
if _, err := svc.RequestLoginCode(ctx, email, "", "ru"); err != nil {
if _, err := svc.RequestLoginCode(ctx, email, "", "ru", false); err != nil {
t.Fatalf("second request: %v", err)
}
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
@@ -255,7 +255,7 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
email := "squat-" + uuid.NewString() + "@example.com"
id, err := svc.RequestLoginCode(ctx, email, "", "en")
id, err := svc.RequestLoginCode(ctx, email, "", "en", false)
if err != nil {
t.Fatalf("request login code: %v", err)
}
@@ -279,6 +279,34 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
}
}
// TestEmailLoginPWAOmitsLink: a login requested from an installed PWA (pwa=true) mails only the
// code — no one-tap confirm link, which would otherwise open in a separate browser out of the
// PWA's reach. A non-PWA request keeps the link.
func TestEmailLoginPWAOmitsLink(t *testing.T) {
ctx := context.Background()
mailer := &capturingMailer{}
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
pwaEmail := "pwa-login-" + uuid.NewString() + "@example.com"
if _, err := svc.RequestLoginCode(ctx, pwaEmail, "", "en", true); err != nil {
t.Fatalf("pwa request login: %v", err)
}
if sixDigit.FindString(mailer.lastBody) == "" {
t.Errorf("pwa login mail must still carry the code, body %q", mailer.lastBody)
}
if confirmToken.MatchString(mailer.lastBody) {
t.Errorf("pwa login mail must omit the one-tap confirm link, body %q", mailer.lastBody)
}
webEmail := "web-login-" + uuid.NewString() + "@example.com"
if _, err := svc.RequestLoginCode(ctx, webEmail, "", "en", false); err != nil {
t.Fatalf("web request login: %v", err)
}
if !confirmToken.MatchString(mailer.lastBody) {
t.Errorf("non-pwa login mail must keep the one-tap confirm link, body %q", mailer.lastBody)
}
}
// confirmToken extracts the one-tap deeplink token from the /app/#/confirm/<token> link
// the branded email carries.
var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`)
@@ -301,7 +329,7 @@ func TestConfirmByTokenLogin(t *testing.T) {
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
email := "tok-login-" + uuid.NewString() + "@example.com"
id, err := svc.RequestLoginCode(ctx, email, "", "en")
id, err := svc.RequestLoginCode(ctx, email, "", "en", false)
if err != nil {
t.Fatalf("request login: %v", err)
}
@@ -382,7 +410,7 @@ func TestEmailAccountSeedsDisplayName(t *testing.T) {
svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru")
local := "kaya-" + uuid.NewString()[:8]
id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en")
id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en", false)
if err != nil {
t.Fatalf("request login: %v", err)
}
+5 -1
View File
@@ -174,6 +174,10 @@ type emailRequest struct {
Email string `json:"email"`
BrowserTZ string `json:"browser_tz"`
Language string `json:"language"`
// Pwa marks a request from an installed PWA (standalone display mode): the login email then
// omits the one-tap confirm link so the code is typed in the same window (the link would open
// in a separate browser whose session cannot reach the PWA). See EmailService.RequestLoginCode.
Pwa bool `json:"pwa"`
}
// handleEmailRequest issues a login confirm-code to the email. It always reports
@@ -185,7 +189,7 @@ func (s *Server) handleEmailRequest(c *gin.Context) {
abortBadRequest(c, "email is required")
return
}
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language); err != nil {
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language, req.Pwa); err != nil {
s.abortErr(c, err)
return
}
+6 -2
View File
@@ -244,7 +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, a change switches the account's email in place,
and a would-be merge is deferred to the interactive flow. The confirm runs on load; the token rides the URL fragment (never
and a would-be merge is deferred to the interactive flow. A login requested from an installed **PWA** omits the deeplink
entirely — its email carries the code only, typed in the same window, since the link would open
in a separate browser whose minted session cannot reach the PWA. 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
@@ -1041,7 +1043,9 @@ link — misses the event; while an add-email confirmation is pending the client
(`internal/adminalert`, started from `main`) emails the operator (`ADMIN_EMAIL`, from a
distinct `SMTP_RELAY_ADMIN_FROM`) when new player feedback or word complaints arrive,
**coalescing** a burst into one digest per interval; both recipients may be several
comma-separated addresses. Both paths are inert unless configured.
comma-separated addresses. The digest carries **no admin-console link** — an admin URL must
never travel in an email, where a mail provider could cache or index it; the operator opens the
console directly. Both paths are inert unless configured.
- Per-request server-side timing via gin middleware from day one (the access log
carries method, route, status, latency and the active trace id). A
client-measured RTT piggybacked on the next request is a later enhancement.
+3 -1
View File
@@ -76,7 +76,9 @@ per address), so a mistyped address or an impatient tap cannot flood an inbox. T
also carries a **one-tap link** that confirms the address — or signs the player straight
in — in a single tap; opening it in another browser reflects in the app at once, and a
mail scanner that merely fetches the link cannot reach it — the token rides the URL
fragment, which is never sent to the server.
fragment, which is never sent to the server. A sign-in requested from an **installed PWA** omits
this link: the email carries only the code, entered in the same window, since the link would open
in a separate browser the app cannot reach.
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
+3 -1
View File
@@ -80,7 +80,9 @@ launch-параметрам VK (их проверяет gateway), и при пе
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
он едет в URL-фрагменте, который не уходит на сервер.
он едет в URL-фрагменте, который не уходит на сервер. Вход, запрошенный из **установленного
PWA**, эту ссылку не содержит: в письме только код, который вводится в том же окне (ссылка
открылась бы в отдельном браузере, недоступном приложению).
Telegram держит **единого бота**: все игроки пользуются одним
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
+4 -3
View File
@@ -289,10 +289,11 @@ 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, language string) error {
// account's time zone, since the email account is created here, not at login. pwa marks a
// request from an installed PWA, so the backend omits the one-tap confirm link from the email.
func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string, pwa bool) error {
return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "",
map[string]string{"email": email, "browser_tz": browserTz, "language": language}, nil)
map[string]any{"email": email, "browser_tz": browserTz, "language": language, "pwa": pwa}, nil)
}
// EmailLogin verifies a login code and mints a session.
+1 -1
View File
@@ -253,7 +253,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()), string(in.Language())); err != nil {
if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz()), string(in.Language()), in.Pwa()); err != nil {
return nil, err
}
return encodeAck(true), nil
+5
View File
@@ -138,6 +138,11 @@ table EmailRequestRequest {
email:string;
browser_tz:string;
language:string;
// Set when the request originates from an installed PWA (standalone display mode): the
// backend then omits the one-tap confirm link from the login email so the code is typed in
// the same window, avoiding a link that opens in a separate browser (a different storage
// context) where the minted session could not reach the PWA.
pwa:bool;
}
// EmailLoginRequest logs in to the account owning email (provisioned at the
+16 -1
View File
@@ -65,8 +65,20 @@ func (rcv *EmailRequestRequest) Language() []byte {
return nil
}
func (rcv *EmailRequestRequest) Pwa() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *EmailRequestRequest) MutatePwa(n bool) bool {
return rcv._tab.MutateBoolSlot(10, n)
}
func EmailRequestRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(3)
builder.StartObject(4)
}
func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
@@ -77,6 +89,9 @@ func EmailRequestRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz fla
func EmailRequestRequestAddLanguage(builder *flatbuffers.Builder, language flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(language), 0)
}
func EmailRequestRequestAddPwa(builder *flatbuffers.Builder, pwa bool) {
builder.PrependBoolSlot(3, pwa, false)
}
func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -41,8 +41,13 @@ language(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
pwa():boolean {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startEmailRequestRequest(builder:flatbuffers.Builder) {
builder.startObject(3);
builder.startObject(4);
}
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
@@ -57,16 +62,21 @@ static addLanguage(builder:flatbuffers.Builder, languageOffset:flatbuffers.Offse
builder.addFieldOffset(2, languageOffset, 0);
}
static addPwa(builder:flatbuffers.Builder, pwa:boolean) {
builder.addFieldInt8(3, +pwa, +false);
}
static endEmailRequestRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, languageOffset:flatbuffers.Offset):flatbuffers.Offset {
static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, languageOffset:flatbuffers.Offset, pwa:boolean):flatbuffers.Offset {
EmailRequestRequest.startEmailRequestRequest(builder);
EmailRequestRequest.addEmail(builder, emailOffset);
EmailRequestRequest.addBrowserTz(builder, browserTzOffset);
EmailRequestRequest.addLanguage(builder, languageOffset);
EmailRequestRequest.addPwa(builder, pwa);
return EmailRequestRequest.endEmailRequestRequest(builder);
}
}
+2 -1
View File
@@ -35,6 +35,7 @@ import {
} from './telegram';
import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
import { pendingVKLink, type VKLinkCallback } from './vkid';
import { isStandalone } from './pwa';
import { registerServiceWorker } from './pwa.svelte';
import { haptic } from './haptics';
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
@@ -980,7 +981,7 @@ export async function loginGuest(): Promise<void> {
export async function requestEmailCode(email: string): Promise<boolean> {
try {
await gateway.authEmailRequest(email, app.locale);
await gateway.authEmailRequest(email, app.locale, isStandalone());
return true;
} catch (err) {
handleError(err);
+1 -1
View File
@@ -65,7 +65,7 @@ export interface GatewayClient {
* cosmetic seed for a brand-new account). */
authVK(params: string, displayName: string): Promise<Session>;
authGuest(locale?: string): Promise<Session>;
authEmailRequest(email: string, language: string): Promise<void>;
authEmailRequest(email: string, language: string, pwa: boolean): Promise<void>;
authEmailLogin(email: string, code: string): Promise<Session>;
/** Confirm a one-tap email deeplink token: a login returns a session to adopt; a
* link returns a status ('confirmed' | 'merge_required'). */
+2 -1
View File
@@ -122,10 +122,11 @@ describe('codec', () => {
expect(guest.browserTz()).toBe('-05:30');
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en')),
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en', true)),
);
expect(email.email()).toBe('a@example.com');
expect(email.browserTz()).toBe('+00:00');
expect(email.pwa()).toBe(true);
expect(email.language()).toBe('en');
const confirm = fb.EmailConfirmLinkRequest.getRootAsEmailConfirmLinkRequest(
+7 -1
View File
@@ -214,7 +214,12 @@ export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
}
export function encodeEmailRequest(email: string, browserTz: string, language: string): Uint8Array {
export function encodeEmailRequest(
email: string,
browserTz: string,
language: string,
pwa: boolean,
): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
const tz = b.createString(browserTz);
@@ -223,6 +228,7 @@ export function encodeEmailRequest(email: string, browserTz: string, language: s
fb.EmailRequestRequest.addEmail(b, e);
fb.EmailRequestRequest.addBrowserTz(b, tz);
fb.EmailRequestRequest.addLanguage(b, l);
fb.EmailRequestRequest.addPwa(b, pwa);
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
}
+1 -1
View File
@@ -150,7 +150,7 @@ export class MockGateway implements GatewayClient {
async authGuest(): Promise<Session> {
return { ...SESSION };
}
async authEmailRequest(_email: string, _language: string): Promise<void> {}
async authEmailRequest(_email: string, _language: string, _pwa: boolean): Promise<void> {}
async confirmEmailLink(_token: string): Promise<ConfirmLinkResult> {
return { purpose: 'link', status: 'confirmed', session: null };
}
+2 -2
View File
@@ -118,8 +118,8 @@ export function createTransport(baseUrl: string): GatewayClient {
async authGuest(locale) {
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
},
async authEmailRequest(email, language) {
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language));
async authEmailRequest(email, language, pwa) {
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language, pwa));
},
async confirmEmailLink(token) {
return codec.decodeConfirmLinkResult(await exec('auth.email.confirm_link', codec.encodeEmailConfirmLink(token)));