Merge pull request 'feat(email): one-tap confirm deeplink + client language (PR1b)' (#162) from feature/email-relay-pr1b into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m3s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m3s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
This commit was merged in pull request #162.
This commit is contained in:
@@ -122,21 +122,35 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
|
|||||||
|
|
||||||
// ProvisionEmail returns the account owning the email identity externalID, creating
|
// ProvisionEmail returns the account owning the email identity externalID, creating
|
||||||
// it on first contact with browserTZ — the client's detected "±HH:MM" UTC offset —
|
// 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
|
// 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
|
// 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
|
// 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,
|
// 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.
|
// 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) {
|
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ, language string) (Account, error) {
|
||||||
return s.provision(ctx, KindEmail, externalID, provisionSeed{
|
return s.provision(ctx, KindEmail, externalID, provisionSeed{
|
||||||
|
displayName: emailDisplayName(externalID),
|
||||||
timeZone: seedZone(browserTZ),
|
timeZone: seedZone(browserTZ),
|
||||||
preferredLanguage: supportedLanguage(language),
|
preferredLanguage: supportedLanguage(language),
|
||||||
isGuest: true,
|
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
|
// 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
|
// "ru"), or "" when it maps to neither, so a new account keeps the 'en' default. It
|
||||||
// accepts region-tagged codes ("ru-RU").
|
// accepts region-tagged codes ("ru-RU").
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
crand "crypto/rand"
|
crand "crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -26,6 +27,11 @@ const (
|
|||||||
emailCodeTTL = 15 * time.Minute
|
emailCodeTTL = 15 * time.Minute
|
||||||
// emailCodeMaxAttempts caps wrong-code submissions before a code is dead.
|
// emailCodeMaxAttempts caps wrong-code submissions before a code is dead.
|
||||||
emailCodeMaxAttempts = 5
|
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
|
// 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)
|
return s.limiter == nil || s.limiter.Allow(email)
|
||||||
}
|
}
|
||||||
|
|
||||||
// issueCode generates a fresh confirm-code for (accountID, email), replaces any prior
|
// issueCode generates a fresh confirm-code and one-tap deeplink token for (accountID,
|
||||||
// pending confirmation, and mails the branded code in locale; purpose selects the
|
// email), replaces any prior pending confirmation, and mails the branded code in
|
||||||
// email wording. Only the SHA-256 hash of the code is stored.
|
// 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 {
|
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string) error {
|
||||||
code, codeHash, err := generateCode()
|
code, codeHash, err := generateCode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
msg, err := renderConfirmationEmail(purpose, code, "", s.baseURL, locale)
|
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg, err := renderConfirmationEmail(purpose, code, s.confirmURL(token, locale), s.baseURL, locale)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -111,6 +122,16 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email
|
|||||||
return s.mailer.Send(ctx, msg)
|
return s.mailer.Send(ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// confirmURL builds the absolute one-tap confirm deeplink for token in locale, or ""
|
||||||
|
// when no public base URL is configured. The locale rides the fragment as ?lang so the
|
||||||
|
// confirm screen (opened in a browser with no session) renders in the email's language.
|
||||||
|
func (s *EmailService) confirmURL(token, locale string) string {
|
||||||
|
if s.baseURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token + "?lang=" + normalizeLocale(locale)
|
||||||
|
}
|
||||||
|
|
||||||
// accountLocale returns the account's preferred UI language for localising email,
|
// accountLocale returns the account's preferred UI language for localising email,
|
||||||
// defaulting to "en" when the account cannot be loaded.
|
// defaulting to "en" when the account cannot be loaded.
|
||||||
func (s *EmailService) accountLocale(ctx context.Context, accountID uuid.UUID) string {
|
func (s *EmailService) accountLocale(ctx context.Context, accountID uuid.UUID) string {
|
||||||
@@ -241,6 +262,75 @@ func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (A
|
|||||||
return s.store.GetByID(ctx, acc.ID)
|
return s.store.GetByID(ctx, acc.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LinkConfirmation is the outcome of confirming a one-tap deeplink token: what the
|
||||||
|
// transport layer must finish. Purpose is the pending row's purpose. For a login,
|
||||||
|
// Account is the account to sign in. For a link, Account is the account the email was
|
||||||
|
// (or would be) attached to; NeedsMerge is set when another account (MergeOwner)
|
||||||
|
// already owns the address, so the caller drives the interactive merge instead of a
|
||||||
|
// plain link — the token is left unconsumed for that merge step.
|
||||||
|
type LinkConfirmation struct {
|
||||||
|
Purpose string
|
||||||
|
Account uuid.UUID
|
||||||
|
NeedsMerge bool
|
||||||
|
MergeOwner uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLogin reports whether the confirmation is a login (the caller mints a session)
|
||||||
|
// rather than a link (attach the identity, or drive a merge).
|
||||||
|
func (r LinkConfirmation) IsLogin() bool { return r.Purpose == purposeLogin }
|
||||||
|
|
||||||
|
// ConfirmByToken verifies a one-tap deeplink token and performs its purpose. A login
|
||||||
|
// confirms the email identity, clears the guest flag and returns the account to sign
|
||||||
|
// in. A link attaches the confirmed email to the pending account when the address is
|
||||||
|
// free, or reports NeedsMerge when another account already owns it (leaving the token
|
||||||
|
// live so the caller's merge step can re-verify). It returns ErrNoPendingCode when the
|
||||||
|
// token matches no live confirmation and ErrCodeExpired when it has lapsed. The token
|
||||||
|
// is high-entropy, so there is no wrong-attempt counter.
|
||||||
|
func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkConfirmation, error) {
|
||||||
|
pend, err := s.store.pendingByTokenHash(ctx, hashCode(token))
|
||||||
|
if err != nil {
|
||||||
|
return LinkConfirmation{}, err
|
||||||
|
}
|
||||||
|
if s.now().After(pend.expiresAt) {
|
||||||
|
return LinkConfirmation{}, ErrCodeExpired
|
||||||
|
}
|
||||||
|
switch pend.purpose {
|
||||||
|
case purposeLogin:
|
||||||
|
if err := s.store.confirmEmailLogin(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
|
||||||
|
return LinkConfirmation{}, err
|
||||||
|
}
|
||||||
|
if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
|
||||||
|
return LinkConfirmation{}, err
|
||||||
|
}
|
||||||
|
return LinkConfirmation{Purpose: purposeLogin, Account: pend.accountID}, nil
|
||||||
|
case purposeLink:
|
||||||
|
owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email)
|
||||||
|
if err != nil {
|
||||||
|
return LinkConfirmation{}, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
if owner == pend.accountID {
|
||||||
|
if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil {
|
||||||
|
return LinkConfirmation{}, err
|
||||||
|
}
|
||||||
|
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
|
||||||
|
}
|
||||||
|
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID, NeedsMerge: true, MergeOwner: owner}, nil
|
||||||
|
}
|
||||||
|
if err := s.store.confirmEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
|
||||||
|
return LinkConfirmation{}, err
|
||||||
|
}
|
||||||
|
// Binding the first email promotes a guest to a durable account, matching the
|
||||||
|
// code-based link flow (which clears the guest flag in the link service).
|
||||||
|
if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
|
||||||
|
return LinkConfirmation{}, err
|
||||||
|
}
|
||||||
|
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
|
||||||
|
default:
|
||||||
|
return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// emailConfirmation is a pending confirm-code row in domain form.
|
// emailConfirmation is a pending confirm-code row in domain form.
|
||||||
type emailConfirmation struct {
|
type emailConfirmation struct {
|
||||||
id uuid.UUID
|
id uuid.UUID
|
||||||
@@ -271,7 +361,7 @@ func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.U
|
|||||||
|
|
||||||
// replacePendingConfirmation clears any pending code for (accountID, email) and
|
// replacePendingConfirmation clears any pending code for (accountID, email) and
|
||||||
// inserts a fresh one, inside one transaction.
|
// 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()
|
id, err := uuid.NewV7()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("account: new confirmation id: %w", err)
|
return fmt.Errorf("account: new confirmation id: %w", err)
|
||||||
@@ -288,7 +378,8 @@ func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.U
|
|||||||
ins := table.EmailConfirmations.INSERT(
|
ins := table.EmailConfirmations.INSERT(
|
||||||
table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID,
|
table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID,
|
||||||
table.EmailConfirmations.Email, table.EmailConfirmations.CodeHash, table.EmailConfirmations.ExpiresAt,
|
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 {
|
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||||
return fmt.Errorf("insert confirmation: %w", err)
|
return fmt.Errorf("insert confirmation: %w", err)
|
||||||
}
|
}
|
||||||
@@ -321,6 +412,54 @@ func (s *Store) latestPendingConfirmation(ctx context.Context, accountID uuid.UU
|
|||||||
}, nil
|
}, 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.
|
// bumpConfirmationAttempts increments a code's wrong-attempt counter by one.
|
||||||
func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error {
|
func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error {
|
||||||
stmt := table.EmailConfirmations.
|
stmt := table.EmailConfirmations.
|
||||||
@@ -414,6 +553,18 @@ func generateCode() (code, hash string, err error) {
|
|||||||
return code, hashCode(code), nil
|
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.
|
// hashCode returns the hex-encoded SHA-256 of a confirm-code.
|
||||||
func hashCode(code string) string {
|
func hashCode(code string) string {
|
||||||
sum := sha256.Sum256([]byte(code))
|
sum := sha256.Sum256([]byte(code))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package account
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,6 +17,52 @@ import (
|
|||||||
// belongs to another account; the caller turns it into a merge.
|
// belongs to another account; the caller turns it into a merge.
|
||||||
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
|
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,
|
// RequestLinkCode issues and mails a confirm-code for email to accountID,
|
||||||
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
||||||
// (taken or already-confirmed): possession of the address is the authorization for
|
// (taken or already-confirmed): possession of the address is the authorization for
|
||||||
|
|||||||
@@ -28,11 +28,13 @@ type UserListItem struct {
|
|||||||
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
|
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
|
||||||
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
|
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
|
||||||
// one char) matched case-insensitively against the display name / any identity's external
|
// 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 {
|
type UserFilter struct {
|
||||||
Robots bool
|
Robots bool
|
||||||
NameMask string
|
NameMask string
|
||||||
ExternalIDMask string
|
ExternalIDMask string
|
||||||
|
EmailExact string
|
||||||
}
|
}
|
||||||
|
|
||||||
// robotExists is the correlated subquery testing whether account a is a robot.
|
// 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)
|
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))
|
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
|
return where, args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,11 @@
|
|||||||
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
|
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{{if .HasEmail}}
|
||||||
|
<form class="form" method="post" action="/_gm/users/{{.ID}}/remove-email" onsubmit="return confirm('Erase the email identity from this account? The address will be freed.')">
|
||||||
|
<button type="submit">Erase email</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Friends</h2>
|
<section class="panel"><h2>Friends</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
|
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
|
||||||
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
|
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
|
||||||
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
|
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
|
||||||
|
<input name="email" value="{{.EmailExact}}" placeholder="email (exact)" type="search">
|
||||||
<button type="submit">Filter</button>
|
<button type="submit">Filter</button>
|
||||||
</form>
|
</form>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ type UsersView struct {
|
|||||||
Robots bool
|
Robots bool
|
||||||
NameMask string
|
NameMask string
|
||||||
ExternalIDMask string
|
ExternalIDMask string
|
||||||
|
EmailExact string
|
||||||
FilterQuery template.URL
|
FilterQuery template.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +162,9 @@ type UserDetailView struct {
|
|||||||
HasStats bool
|
HasStats bool
|
||||||
Stats StatsRow
|
Stats StatsRow
|
||||||
Identities []IdentityRow
|
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 and VKID are the account's platform external ids (empty when absent).
|
||||||
// TelegramID gates the "Send Telegram message" operator action; VKID surfaces the VK
|
// 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).
|
// user id with a link to the VK profile (there is no VK messaging to drive).
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -278,3 +278,148 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
|
|||||||
t.Error("confirming the login must clear the guest flag (promote to durable)")
|
t.Error("confirming the login must clear the guest flag (promote to durable)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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_-]+)`)
|
||||||
|
|
||||||
|
func tokenFromMail(t *testing.T, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
m := confirmToken.FindStringSubmatch(body)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatalf("no confirm token in mail body %q", body)
|
||||||
|
}
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfirmByTokenLogin: the one-tap deeplink token completes an email login,
|
||||||
|
// clearing the guest flag.
|
||||||
|
func TestConfirmByTokenLogin(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
email := "tok-login-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
|
id, err := svc.RequestLoginCode(ctx, email, "", "en")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request login: %v", err)
|
||||||
|
}
|
||||||
|
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("confirm by token: %v", err)
|
||||||
|
}
|
||||||
|
if !res.IsLogin() || res.Account != id {
|
||||||
|
t.Fatalf("login result = %+v, want login for %s", res, id)
|
||||||
|
}
|
||||||
|
if !identityConfirmed(t, account.KindEmail, email) {
|
||||||
|
t.Error("email identity must be confirmed after the token login")
|
||||||
|
}
|
||||||
|
if acc, _ := store.GetByID(ctx, id); acc.IsGuest {
|
||||||
|
t.Error("the token login must clear the guest flag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfirmByTokenLink: the token attaches a free email to the requesting account.
|
||||||
|
func TestConfirmByTokenLink(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
email := "tok-link-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
|
if err := svc.RequestLinkCode(ctx, acc, email); err != nil {
|
||||||
|
t.Fatalf("request link: %v", err)
|
||||||
|
}
|
||||||
|
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("confirm by token: %v", err)
|
||||||
|
}
|
||||||
|
if res.IsLogin() || res.NeedsMerge || res.Account != acc {
|
||||||
|
t.Fatalf("link result = %+v, want a plain link for %s", res, acc)
|
||||||
|
}
|
||||||
|
if !identityConfirmed(t, account.KindEmail, email) {
|
||||||
|
t.Error("email identity must be confirmed after the token link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfirmByTokenLinkMerge: a token for an address owned by another account signals
|
||||||
|
// a merge (leaving the token unconsumed for the interactive step).
|
||||||
|
func TestConfirmByTokenLinkMerge(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
email := "tok-merge-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
|
owner := provisionAccount(t)
|
||||||
|
if err := svc.RequestCode(ctx, owner, email); err != nil {
|
||||||
|
t.Fatalf("owner request: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ConfirmCode(ctx, owner, email, sixDigit.FindString(mailer.lastBody)); err != nil {
|
||||||
|
t.Fatalf("owner confirm: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
other := provisionAccount(t)
|
||||||
|
if err := svc.RequestLinkCode(ctx, other, email); err != nil {
|
||||||
|
t.Fatalf("link request: %v", err)
|
||||||
|
}
|
||||||
|
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("confirm by token: %v", err)
|
||||||
|
}
|
||||||
|
if !res.NeedsMerge || res.MergeOwner != owner {
|
||||||
|
t.Fatalf("merge result = %+v, want NeedsMerge with owner=%s", res, owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmailAccountSeedsDisplayName seeds a new email account's display name from the
|
||||||
|
// email's local part, so an email login is not left nameless.
|
||||||
|
func TestEmailAccountSeedsDisplayName(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru")
|
||||||
|
local := "kaya-" + uuid.NewString()[:8]
|
||||||
|
|
||||||
|
id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request login: %v", err)
|
||||||
|
}
|
||||||
|
acc, err := store.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if acc.DisplayName != local {
|
||||||
|
t.Errorf("display name = %q, want the email local part %q", acc.DisplayName, local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfirmByTokenLinkClearsGuest: binding an email to a guest via the deeplink
|
||||||
|
// promotes the guest to a durable account (the deeplink path must match the
|
||||||
|
// code-based link flow, which clears the guest flag).
|
||||||
|
func TestConfirmByTokenLinkClearsGuest(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
guest, err := store.ProvisionGuest(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision guest: %v", err)
|
||||||
|
}
|
||||||
|
email := "guest-link-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
|
if err := svc.RequestLinkCode(ctx, guest.ID, email); err != nil {
|
||||||
|
t.Fatalf("request link: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody)); err != nil {
|
||||||
|
t.Fatalf("confirm by token: %v", err)
|
||||||
|
}
|
||||||
|
acc, err := store.GetByID(ctx, guest.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if acc.IsGuest {
|
||||||
|
t.Error("linking an email via the deeplink must promote the guest to durable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -155,6 +155,13 @@ func Notification(userID uuid.UUID, kind string) Intent {
|
|||||||
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
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
|
// 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
|
// 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.
|
// requests/friends lists and the in-game "add friend" state without a refetch.
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ const (
|
|||||||
// it re-fetches profile.get to show or hide the banner. It carries no payload (the
|
// 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.
|
// banner set rides the profile response). In-app only.
|
||||||
NotifyBanner = "banner"
|
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,
|
// 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
|
// 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
|
// in-game block/add-friend controls and the struck name in place. It is delivered
|
||||||
|
|||||||
@@ -21,4 +21,6 @@ type EmailConfirmations struct {
|
|||||||
Attempts int16
|
Attempts int16
|
||||||
ConsumedAt *time.Time
|
ConsumedAt *time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
Purpose string
|
||||||
|
LinkTokenHash *string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type emailConfirmationsTable struct {
|
|||||||
Attempts postgres.ColumnInteger
|
Attempts postgres.ColumnInteger
|
||||||
ConsumedAt postgres.ColumnTimestampz
|
ConsumedAt postgres.ColumnTimestampz
|
||||||
CreatedAt postgres.ColumnTimestampz
|
CreatedAt postgres.ColumnTimestampz
|
||||||
|
Purpose postgres.ColumnString
|
||||||
|
LinkTokenHash postgres.ColumnString
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -74,9 +76,11 @@ func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailCo
|
|||||||
AttemptsColumn = postgres.IntegerColumn("attempts")
|
AttemptsColumn = postgres.IntegerColumn("attempts")
|
||||||
ConsumedAtColumn = postgres.TimestampzColumn("consumed_at")
|
ConsumedAtColumn = postgres.TimestampzColumn("consumed_at")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
|
PurposeColumn = postgres.StringColumn("purpose")
|
||||||
mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
|
LinkTokenHashColumn = postgres.StringColumn("link_token_hash")
|
||||||
defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn}
|
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{
|
return emailConfirmationsTable{
|
||||||
@@ -91,6 +95,8 @@ func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailCo
|
|||||||
Attempts: AttemptsColumn,
|
Attempts: AttemptsColumn,
|
||||||
ConsumedAt: ConsumedAtColumn,
|
ConsumedAt: ConsumedAtColumn,
|
||||||
CreatedAt: CreatedAtColumn,
|
CreatedAt: CreatedAtColumn,
|
||||||
|
Purpose: PurposeColumn,
|
||||||
|
LinkTokenHash: LinkTokenHashColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -31,6 +31,7 @@ func (s *Server) registerRoutes() {
|
|||||||
in.POST("/sessions/guest", s.handleGuestAuth)
|
in.POST("/sessions/guest", s.handleGuestAuth)
|
||||||
in.POST("/sessions/email/request", s.handleEmailRequest)
|
in.POST("/sessions/email/request", s.handleEmailRequest)
|
||||||
in.POST("/sessions/email/login", s.handleEmailLogin)
|
in.POST("/sessions/email/login", s.handleEmailLogin)
|
||||||
|
in.POST("/sessions/email/confirm-link", s.handleEmailConfirmLink)
|
||||||
in.POST("/sessions/resolve", s.handleResolveSession)
|
in.POST("/sessions/resolve", s.handleResolveSession)
|
||||||
in.POST("/sessions/revoke", s.handleRevokeSession)
|
in.POST("/sessions/revoke", s.handleRevokeSession)
|
||||||
// Out-of-app push routing for the platform side-service: the
|
// Out-of-app push routing for the platform side-service: the
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
|
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
|
||||||
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
||||||
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
|
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
|
||||||
|
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
|
||||||
gm.GET("/reasons", s.consoleReasons)
|
gm.GET("/reasons", s.consoleReasons)
|
||||||
gm.POST("/reasons", s.consoleCreateReason)
|
gm.POST("/reasons", s.consoleCreateReason)
|
||||||
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
|
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
|
||||||
@@ -134,6 +135,7 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
|||||||
Robots: c.Query("kind") == "robots",
|
Robots: c.Query("kind") == "robots",
|
||||||
NameMask: c.Query("name"),
|
NameMask: c.Query("name"),
|
||||||
ExternalIDMask: c.Query("ext"),
|
ExternalIDMask: c.Query("ext"),
|
||||||
|
EmailExact: c.Query("email"),
|
||||||
}
|
}
|
||||||
total, _ := s.accounts.CountUsers(ctx, filter)
|
total, _ := s.accounts.CountUsers(ctx, filter)
|
||||||
items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize)
|
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) != "" {
|
if strings.TrimSpace(filter.ExternalIDMask) != "" {
|
||||||
q.Set("ext", filter.ExternalIDMask)
|
q.Set("ext", filter.ExternalIDMask)
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(filter.EmailExact) != "" {
|
||||||
|
q.Set("email", filter.EmailExact)
|
||||||
|
}
|
||||||
view := adminconsole.UsersView{
|
view := adminconsole.UsersView{
|
||||||
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
||||||
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
|
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
|
||||||
|
EmailExact: filter.EmailExact,
|
||||||
FilterQuery: template.URL(q.Encode()),
|
FilterQuery: template.URL(q.Encode()),
|
||||||
}
|
}
|
||||||
ids := make([]uuid.UUID, 0, len(items))
|
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 {
|
if ids, err := s.accounts.Identities(ctx, id); err == nil {
|
||||||
for _, idn := range ids {
|
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)})
|
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)
|
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
|
// 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
|
// 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.
|
// games, removing them from matchmaking. The block takes effect on the player's next request.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
|
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
|
||||||
@@ -212,6 +213,56 @@ func (s *Server) handleEmailLogin(c *gin.Context) {
|
|||||||
s.mintSession(c, acc)
|
s.mintSession(c, acc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// confirmLinkResponse is the outcome of a one-tap deeplink confirmation. For a login,
|
||||||
|
// Session carries the freshly minted credential (the deeplink page signs in with it);
|
||||||
|
// for a link, Status is "confirmed" or "merge_required" (the app completes the merge).
|
||||||
|
type confirmLinkResponse struct {
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Session *sessionResponse `json:"session,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEmailConfirmLink verifies a one-tap deeplink token. A login mints a session
|
||||||
|
// (the deeplink page signs in with it); a link attaches the confirmed email, reporting
|
||||||
|
// "merge_required" when the address is owned by another account so the app drives the
|
||||||
|
// interactive merge. The token, not a request session, is the authorization — the page
|
||||||
|
// need not be signed in.
|
||||||
|
func (s *Server) handleEmailConfirmLink(c *gin.Context) {
|
||||||
|
var req tokenRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
|
||||||
|
abortBadRequest(c, "token is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := s.emails.ConfirmByToken(c.Request.Context(), req.Token)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.IsLogin() {
|
||||||
|
acc, err := s.accounts.GetByID(c.Request.Context(), res.Account)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess := sessionResponseFor(token, acc)
|
||||||
|
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "login", Status: "confirmed", Session: &sess})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.NeedsMerge {
|
||||||
|
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: "merge_required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A free link attached the email; nudge the account's in-app session(s) to re-fetch
|
||||||
|
// the profile, so a link confirmed in another browser reflects at once.
|
||||||
|
s.notifier.Publish(notify.ProfileChanged(res.Account))
|
||||||
|
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: "confirmed"})
|
||||||
|
}
|
||||||
|
|
||||||
// tokenRequest carries an opaque session token.
|
// tokenRequest carries an opaque session token.
|
||||||
type tokenRequest struct {
|
type tokenRequest struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
|
|||||||
+12
-2
@@ -239,7 +239,14 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
development log mailer when no relay is configured) and, once
|
development log mailer when no relay is configured) and, once
|
||||||
verified, attaches a confirmed email identity. Sends are throttled per recipient
|
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 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/<token>`, an opaque
|
||||||
|
256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the
|
||||||
|
browser that opens it (magic-link), a link confirms the identity and emits a `notify`
|
||||||
|
profile-refresh to the in-app session, and a would-be merge is deferred to the
|
||||||
|
interactive flow. The confirm runs on load; the token rides the URL fragment (never
|
||||||
|
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
|
**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
|
code is confirmed — so an abandoned, never-confirmed login frees its reserved
|
||||||
address — with confirming clearing the flag. Accounts and identities use
|
address — with confirming clearing the flag. Accounts and identities use
|
||||||
@@ -933,7 +940,10 @@ edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are o
|
|||||||
eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets
|
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),
|
`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*
|
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
|
> 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
|
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
|
||||||
|
|||||||
+5
-1
@@ -66,7 +66,11 @@ client's light/dark scheme, and the layout clears the VK mobile home bar. The sa
|
|||||||
is provisioned on the first request but only becomes a durable account once the code
|
is 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
|
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
|
freed. Sends are rate-limited (a short cooldown between codes and a small hourly cap
|
||||||
per address), so a mistyped address or an impatient tap cannot flood an inbox.
|
per address), so a mistyped address or an impatient tap cannot flood an inbox. The email
|
||||||
|
also carries a **one-tap link** that confirms the address — or signs the player straight
|
||||||
|
in — in a single tap; opening it in another browser reflects in the app at once, and a
|
||||||
|
mail scanner that merely fetches the link cannot reach it — the token rides the URL
|
||||||
|
fragment, which is never sent to the server.
|
||||||
Telegram runs a **single bot**: every player uses
|
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
|
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
|
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
|||||||
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
||||||
неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая
|
неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая
|
||||||
пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап
|
пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап
|
||||||
не завалили почтовый ящик.
|
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
|
||||||
|
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
|
||||||
|
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
|
||||||
|
он едет в URL-фрагменте, который не уходит на сервер.
|
||||||
Telegram держит **единого бота**: все игроки пользуются одним
|
Telegram держит **единого бота**: все игроки пользуются одним
|
||||||
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
||||||
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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.
|
// 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", "", "",
|
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.
|
// 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
|
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
|
// 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.
|
// 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) {
|
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, error) {
|
||||||
|
|||||||
@@ -37,6 +37,35 @@ func encodeAck(ok bool) []byte {
|
|||||||
return b.FinishedBytes()
|
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
|
// encodeProfile builds a Profile payload, including the advertising-banner block
|
||||||
// when the backend marked the viewer eligible.
|
// when the backend marked the viewer eligible.
|
||||||
func encodeProfile(p backendclient.ProfileResp) []byte {
|
func encodeProfile(p backendclient.ProfileResp) []byte {
|
||||||
|
|||||||
@@ -19,37 +19,38 @@ import (
|
|||||||
|
|
||||||
// Message types in the vertical slice.
|
// Message types in the vertical slice.
|
||||||
const (
|
const (
|
||||||
MsgAuthTelegram = "auth.telegram"
|
MsgAuthTelegram = "auth.telegram"
|
||||||
MsgAuthVK = "auth.vk"
|
MsgAuthVK = "auth.vk"
|
||||||
MsgAuthGuest = "auth.guest"
|
MsgAuthGuest = "auth.guest"
|
||||||
MsgAuthEmailReq = "auth.email.request"
|
MsgAuthEmailReq = "auth.email.request"
|
||||||
MsgAuthEmailLogin = "auth.email.login"
|
MsgAuthEmailLogin = "auth.email.login"
|
||||||
MsgProfileGet = "profile.get"
|
MsgAuthEmailConfirmLink = "auth.email.confirm_link"
|
||||||
MsgBlockStatus = "account.block_status"
|
MsgProfileGet = "profile.get"
|
||||||
MsgGameSubmitPlay = "game.submit_play"
|
MsgBlockStatus = "account.block_status"
|
||||||
MsgGameState = "game.state"
|
MsgGameSubmitPlay = "game.submit_play"
|
||||||
MsgLobbyEnqueue = "lobby.enqueue"
|
MsgGameState = "game.state"
|
||||||
MsgLobbyCancel = "lobby.cancel"
|
MsgLobbyEnqueue = "lobby.enqueue"
|
||||||
MsgLobbyPoll = "lobby.poll"
|
MsgLobbyCancel = "lobby.cancel"
|
||||||
MsgChatPost = "chat.post"
|
MsgLobbyPoll = "lobby.poll"
|
||||||
MsgGamesList = "games.list"
|
MsgChatPost = "chat.post"
|
||||||
MsgGamePass = "game.pass"
|
MsgGamesList = "games.list"
|
||||||
MsgGameExchange = "game.exchange"
|
MsgGamePass = "game.pass"
|
||||||
MsgGameResign = "game.resign"
|
MsgGameExchange = "game.exchange"
|
||||||
MsgGameHint = "game.hint"
|
MsgGameResign = "game.resign"
|
||||||
MsgGameEvaluate = "game.evaluate"
|
MsgGameHint = "game.hint"
|
||||||
MsgGameCheckWord = "game.check_word"
|
MsgGameEvaluate = "game.evaluate"
|
||||||
MsgGameComplaint = "game.complaint"
|
MsgGameCheckWord = "game.check_word"
|
||||||
MsgGameHistory = "game.history"
|
MsgGameComplaint = "game.complaint"
|
||||||
MsgChatList = "chat.list"
|
MsgGameHistory = "game.history"
|
||||||
MsgChatNudge = "chat.nudge"
|
MsgChatList = "chat.list"
|
||||||
MsgChatRead = "chat.read"
|
MsgChatNudge = "chat.nudge"
|
||||||
MsgDraftGet = "draft.get"
|
MsgChatRead = "chat.read"
|
||||||
MsgDraftSave = "draft.save"
|
MsgDraftGet = "draft.get"
|
||||||
MsgGameHide = "game.hide"
|
MsgDraftSave = "draft.save"
|
||||||
MsgFeedbackSubmit = "feedback.submit"
|
MsgGameHide = "game.hide"
|
||||||
MsgFeedbackGet = "feedback.get"
|
MsgFeedbackSubmit = "feedback.submit"
|
||||||
MsgFeedbackUnread = "feedback.unread"
|
MsgFeedbackGet = "feedback.get"
|
||||||
|
MsgFeedbackUnread = "feedback.unread"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Request is one decoded Execute call.
|
// 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[MsgAuthGuest] = Op{Handler: authGuestHandler(backend)}
|
||||||
r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true}
|
r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true}
|
||||||
r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(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[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true}
|
||||||
r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true}
|
r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true}
|
||||||
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(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 {
|
func authEmailRequestHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsEmailRequestRequest(req.Payload, 0)
|
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 nil, err
|
||||||
}
|
}
|
||||||
return encodeAck(true), nil
|
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 {
|
func profileHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
p, err := backend.Profile(ctx, req.UserID)
|
p, err := backend.Profile(ctx, req.UserID)
|
||||||
|
|||||||
+18
-1
@@ -132,10 +132,12 @@ table GuestLoginRequest {
|
|||||||
|
|
||||||
// EmailRequestRequest asks the backend to send a login confirm-code to email. It
|
// 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
|
// 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 {
|
table EmailRequestRequest {
|
||||||
email:string;
|
email:string;
|
||||||
browser_tz:string;
|
browser_tz:string;
|
||||||
|
language:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailLoginRequest logs in to the account owning email (provisioned at the
|
// EmailLoginRequest logs in to the account owning email (provisioned at the
|
||||||
@@ -145,6 +147,21 @@ table EmailLoginRequest {
|
|||||||
code:string;
|
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.
|
// Session is the minted credential returned by every auth operation.
|
||||||
table Session {
|
table Session {
|
||||||
token:string;
|
token:string;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -57,8 +57,16 @@ func (rcv *EmailRequestRequest) BrowserTz() []byte {
|
|||||||
return nil
|
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) {
|
func EmailRequestRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(2)
|
builder.StartObject(3)
|
||||||
}
|
}
|
||||||
func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
|
func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
|
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) {
|
func EmailRequestRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
|
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 {
|
func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import NewGame from './screens/NewGame.svelte';
|
import NewGame from './screens/NewGame.svelte';
|
||||||
import SettingsHub from './screens/SettingsHub.svelte';
|
import SettingsHub from './screens/SettingsHub.svelte';
|
||||||
import Stats from './screens/Stats.svelte';
|
import Stats from './screens/Stats.svelte';
|
||||||
|
import Confirm from './screens/Confirm.svelte';
|
||||||
import Game from './game/Game.svelte';
|
import Game from './game/Game.svelte';
|
||||||
import CommsHub from './game/CommsHub.svelte';
|
import CommsHub from './game/CommsHub.svelte';
|
||||||
import Feedback from './screens/Feedback.svelte';
|
import Feedback from './screens/Feedback.svelte';
|
||||||
@@ -110,6 +111,8 @@
|
|||||||
<Feedback />
|
<Feedback />
|
||||||
{:else if router.route.name === 'stats'}
|
{:else if router.route.name === 'stats'}
|
||||||
<Stats />
|
<Stats />
|
||||||
|
{:else if router.route.name === 'confirm'}
|
||||||
|
<Confirm token={router.route.params.token} />
|
||||||
{:else}
|
{:else}
|
||||||
<Lobby />
|
<Lobby />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export { ComplaintRequest } from './scrabblefb/complaint-request.js';
|
|||||||
export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js';
|
export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js';
|
||||||
export { DraftRequest } from './scrabblefb/draft-request.js';
|
export { DraftRequest } from './scrabblefb/draft-request.js';
|
||||||
export { DraftView } from './scrabblefb/draft-view.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 { EmailLoginRequest } from './scrabblefb/email-login-request.js';
|
||||||
export { EmailRequestRequest } from './scrabblefb/email-request-request.js';
|
export { EmailRequestRequest } from './scrabblefb/email-request-request.js';
|
||||||
export { EnqueueRequest } from './scrabblefb/enqueue-request.js';
|
export { EnqueueRequest } from './scrabblefb/enqueue-request.js';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -34,8 +34,15 @@ browserTz(optionalEncoding?:any):string|Uint8Array|null {
|
|||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : 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) {
|
static startEmailRequestRequest(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(2);
|
builder.startObject(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
|
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);
|
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 {
|
static endEmailRequestRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
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.startEmailRequestRequest(builder);
|
||||||
EmailRequestRequest.addEmail(builder, emailOffset);
|
EmailRequestRequest.addEmail(builder, emailOffset);
|
||||||
EmailRequestRequest.addBrowserTz(builder, browserTzOffset);
|
EmailRequestRequest.addBrowserTz(builder, browserTzOffset);
|
||||||
|
EmailRequestRequest.addLanguage(builder, languageOffset);
|
||||||
return EmailRequestRequest.endEmailRequestRequest(builder);
|
return EmailRequestRequest.endEmailRequestRequest(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,6 +409,11 @@ function openStream(): void {
|
|||||||
if (e.sub === 'banner') {
|
if (e.sub === 'banner') {
|
||||||
void refreshProfile();
|
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();
|
void refreshNotifications();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -738,7 +743,7 @@ export async function bootstrap(): Promise<void> {
|
|||||||
if (saved) {
|
if (saved) {
|
||||||
await adoptSession(saved);
|
await adoptSession(saved);
|
||||||
if (router.route.name === 'login') navigate('/');
|
if (router.route.name === 'login') navigate('/');
|
||||||
} else if (router.route.name !== 'login') {
|
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
app.ready = true;
|
app.ready = true;
|
||||||
@@ -915,7 +920,7 @@ export async function loginGuest(): Promise<void> {
|
|||||||
|
|
||||||
export async function requestEmailCode(email: string): Promise<boolean> {
|
export async function requestEmailCode(email: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await gateway.authEmailRequest(email);
|
await gateway.authEmailRequest(email, app.locale);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err);
|
handleError(err);
|
||||||
@@ -933,6 +938,20 @@ export async function loginEmail(email: string, code: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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<void> {
|
export async function logout(): Promise<void> {
|
||||||
closeStream();
|
closeStream();
|
||||||
resetConnection();
|
resetConnection();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
HintResult,
|
HintResult,
|
||||||
Invitation,
|
Invitation,
|
||||||
InvitationSettings,
|
InvitationSettings,
|
||||||
|
ConfirmLinkResult,
|
||||||
LinkResult,
|
LinkResult,
|
||||||
MatchResult,
|
MatchResult,
|
||||||
MoveResult,
|
MoveResult,
|
||||||
@@ -64,8 +65,11 @@ export interface GatewayClient {
|
|||||||
* cosmetic seed for a brand-new account). */
|
* cosmetic seed for a brand-new account). */
|
||||||
authVK(params: string, displayName: string): Promise<Session>;
|
authVK(params: string, displayName: string): Promise<Session>;
|
||||||
authGuest(locale?: string): Promise<Session>;
|
authGuest(locale?: string): Promise<Session>;
|
||||||
authEmailRequest(email: string): Promise<void>;
|
authEmailRequest(email: string, language: string): Promise<void>;
|
||||||
authEmailLogin(email: string, code: string): Promise<Session>;
|
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'). */
|
||||||
|
confirmEmailLink(token: string): Promise<ConfirmLinkResult>;
|
||||||
|
|
||||||
// --- profile / lists ---
|
// --- profile / lists ---
|
||||||
profileGet(): Promise<Profile>;
|
profileGet(): Promise<Profile>;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
decodeStats,
|
decodeStats,
|
||||||
encodeCheckWord,
|
encodeCheckWord,
|
||||||
encodeEmailRequest,
|
encodeEmailRequest,
|
||||||
|
encodeEmailConfirmLink,
|
||||||
encodeFeedbackSubmit,
|
encodeFeedbackSubmit,
|
||||||
encodeDraftSave,
|
encodeDraftSave,
|
||||||
encodeEnqueue,
|
encodeEnqueue,
|
||||||
@@ -117,10 +118,16 @@ describe('codec', () => {
|
|||||||
expect(guest.browserTz()).toBe('-05:30');
|
expect(guest.browserTz()).toBe('-05:30');
|
||||||
|
|
||||||
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
|
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.email()).toBe('a@example.com');
|
||||||
expect(email.browserTz()).toBe('+00:00');
|
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(
|
const vk = fb.VKLoginRequest.getRootAsVKLoginRequest(
|
||||||
new ByteBuffer(encodeVKLogin('vk_user_id=494075&vk_ts=1&sign=abc', '+03:00', 'Иван Петров')),
|
new ByteBuffer(encodeVKLogin('vk_user_id=494075&vk_ts=1&sign=abc', '+03:00', 'Иван Петров')),
|
||||||
|
|||||||
+22
-1
@@ -31,6 +31,7 @@ import type {
|
|||||||
Invitation,
|
Invitation,
|
||||||
InvitationInvitee,
|
InvitationInvitee,
|
||||||
InvitationSettings,
|
InvitationSettings,
|
||||||
|
ConfirmLinkResult,
|
||||||
LinkResult,
|
LinkResult,
|
||||||
MatchResult,
|
MatchResult,
|
||||||
MoveRecord,
|
MoveRecord,
|
||||||
@@ -212,13 +213,15 @@ export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array
|
|||||||
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
|
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 b = new Builder(128);
|
||||||
const e = b.createString(email);
|
const e = b.createString(email);
|
||||||
const tz = b.createString(browserTz);
|
const tz = b.createString(browserTz);
|
||||||
|
const l = b.createString(language);
|
||||||
fb.EmailRequestRequest.startEmailRequestRequest(b);
|
fb.EmailRequestRequest.startEmailRequestRequest(b);
|
||||||
fb.EmailRequestRequest.addEmail(b, e);
|
fb.EmailRequestRequest.addEmail(b, e);
|
||||||
fb.EmailRequestRequest.addBrowserTz(b, tz);
|
fb.EmailRequestRequest.addBrowserTz(b, tz);
|
||||||
|
fb.EmailRequestRequest.addLanguage(b, l);
|
||||||
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
|
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));
|
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 ---
|
// --- response decoders ---
|
||||||
|
|
||||||
function s(v: string | null): string {
|
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 ---
|
// --- social decoders ---
|
||||||
|
|
||||||
function decodeAccountRef(r: fb.AccountRef): AccountRef {
|
function decodeAccountRef(r: fb.AccountRef): AccountRef {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
export const en = {
|
export const en = {
|
||||||
'app.title': 'Scrabble',
|
'app.title': 'Scrabble',
|
||||||
|
'app.brand': 'Erudit',
|
||||||
'dict.loading': 'Loading…',
|
'dict.loading': 'Loading…',
|
||||||
'connection.connecting': 'Connecting…',
|
'connection.connecting': 'Connecting…',
|
||||||
|
|
||||||
@@ -36,6 +37,11 @@ export const en = {
|
|||||||
'login.sendCode': 'Send code',
|
'login.sendCode': 'Send code',
|
||||||
'login.codePlaceholder': '6-digit code',
|
'login.codePlaceholder': '6-digit code',
|
||||||
'login.signIn': 'Sign in',
|
'login.signIn': 'Sign in',
|
||||||
|
'confirm.busy': 'Confirming your email…',
|
||||||
|
'confirm.linked': 'Email confirmed.',
|
||||||
|
'confirm.merge': 'Almost done — finish the merge in the app.',
|
||||||
|
'confirm.close': 'You can close this window and return to the game.',
|
||||||
|
'confirm.expired': 'This link is invalid or has expired. Request a new code.',
|
||||||
'login.codeSent': 'We sent a code to {email}.',
|
'login.codeSent': 'We sent a code to {email}.',
|
||||||
|
|
||||||
'lobby.activeGames': 'Active games',
|
'lobby.activeGames': 'Active games',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { MessageKey } from './en';
|
|||||||
|
|
||||||
export const ru: Record<MessageKey, string> = {
|
export const ru: Record<MessageKey, string> = {
|
||||||
'app.title': 'Scrabble',
|
'app.title': 'Scrabble',
|
||||||
|
'app.brand': 'Эрудит',
|
||||||
'dict.loading': 'Загрузка…',
|
'dict.loading': 'Загрузка…',
|
||||||
'connection.connecting': 'Подключение…',
|
'connection.connecting': 'Подключение…',
|
||||||
|
|
||||||
@@ -37,6 +38,11 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'login.sendCode': 'Отправить код',
|
'login.sendCode': 'Отправить код',
|
||||||
'login.codePlaceholder': 'Код из 6 цифр',
|
'login.codePlaceholder': 'Код из 6 цифр',
|
||||||
'login.signIn': 'Войти',
|
'login.signIn': 'Войти',
|
||||||
|
'confirm.busy': 'Подтверждаем почту…',
|
||||||
|
'confirm.linked': 'Почта подтверждена.',
|
||||||
|
'confirm.merge': 'Почти готово — завершите объединение в приложении.',
|
||||||
|
'confirm.close': 'Можно закрыть это окно и вернуться в игру.',
|
||||||
|
'confirm.expired': 'Ссылка недействительна или истекла. Запросите новый код.',
|
||||||
'login.codeSent': 'Мы отправили код на {email}.',
|
'login.codeSent': 'Мы отправили код на {email}.',
|
||||||
|
|
||||||
'lobby.activeGames': 'Активные игры',
|
'lobby.activeGames': 'Активные игры',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
HintResult,
|
HintResult,
|
||||||
Invitation,
|
Invitation,
|
||||||
InvitationSettings,
|
InvitationSettings,
|
||||||
|
ConfirmLinkResult,
|
||||||
LinkResult,
|
LinkResult,
|
||||||
MatchResult,
|
MatchResult,
|
||||||
MoveResult,
|
MoveResult,
|
||||||
@@ -149,7 +150,10 @@ export class MockGateway implements GatewayClient {
|
|||||||
async authGuest(): Promise<Session> {
|
async authGuest(): Promise<Session> {
|
||||||
return { ...SESSION };
|
return { ...SESSION };
|
||||||
}
|
}
|
||||||
async authEmailRequest(): Promise<void> {}
|
async authEmailRequest(_email: string, _language: string): Promise<void> {}
|
||||||
|
async confirmEmailLink(_token: string): Promise<ConfirmLinkResult> {
|
||||||
|
return { purpose: 'link', status: 'confirmed', session: null };
|
||||||
|
}
|
||||||
async authEmailLogin(): Promise<Session> {
|
async authEmailLogin(): Promise<Session> {
|
||||||
return { ...SESSION, isGuest: false };
|
return { ...SESSION, isGuest: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,15 @@ export interface LinkResult {
|
|||||||
session: Session | null;
|
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 {
|
export interface MatchResult {
|
||||||
matched: boolean;
|
matched: boolean;
|
||||||
game?: GameView;
|
game?: GameView;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type RouteName =
|
|||||||
| 'friends'
|
| 'friends'
|
||||||
| 'feedback'
|
| 'feedback'
|
||||||
| 'stats'
|
| 'stats'
|
||||||
|
| 'confirm'
|
||||||
| 'notfound';
|
| 'notfound';
|
||||||
|
|
||||||
export interface Route {
|
export interface Route {
|
||||||
@@ -58,6 +59,11 @@ export function parse(hash: string): Route {
|
|||||||
return { name: 'feedback', params: {} };
|
return { name: 'feedback', params: {} };
|
||||||
case 'stats':
|
case 'stats':
|
||||||
return { name: 'stats', params: {} };
|
return { name: 'stats', params: {} };
|
||||||
|
case 'confirm':
|
||||||
|
// The one-tap email deeplink: /confirm/<token>. 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:
|
default:
|
||||||
return { name: 'notfound', params: {} };
|
return { name: 'notfound', params: {} };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,11 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async authGuest(locale) {
|
async authGuest(locale) {
|
||||||
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
|
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
|
||||||
},
|
},
|
||||||
async authEmailRequest(email) {
|
async authEmailRequest(email, language) {
|
||||||
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset()));
|
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) {
|
async authEmailLogin(email, code) {
|
||||||
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code)));
|
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code)));
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { confirmDeeplink } from '../lib/app.svelte';
|
||||||
|
import { setLocale, t } from '../lib/i18n/index.svelte';
|
||||||
|
|
||||||
|
let { token }: { token: string } = $props();
|
||||||
|
// busy → (a login navigates into the app) | linked | merge | error. The token rides
|
||||||
|
// the URL fragment (#/confirm/<token>), which is never sent to the server, so a plain
|
||||||
|
// link prefetch cannot reach it — confirm on load. The manual six-digit code in the
|
||||||
|
// same email is the fallback if an aggressive scanner runs the page first.
|
||||||
|
let stage = $state<'busy' | 'linked' | 'merge' | 'error'>('busy');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// The email carries the recipient's language on the link (?lang), so this
|
||||||
|
// session-less page renders in it rather than the default.
|
||||||
|
const query = window.location.hash.split('?')[1] ?? '';
|
||||||
|
const lang = new URLSearchParams(query).get('lang');
|
||||||
|
if (lang === 'ru' || lang === 'en') setLocale(lang);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
stage = 'error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await confirmDeeplink(token);
|
||||||
|
stage = r === 'merge_required' ? 'merge' : 'linked';
|
||||||
|
} catch {
|
||||||
|
stage = 'error';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="confirm">
|
||||||
|
<div class="card">
|
||||||
|
{#if stage === 'busy'}
|
||||||
|
<p class="muted">{t('confirm.busy')}</p>
|
||||||
|
{:else if stage === 'error'}
|
||||||
|
<p class="err">{t('confirm.expired')}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="brand">{t('app.brand')}</div>
|
||||||
|
<p class="ok">{stage === 'merge' ? t('confirm.merge') : t('confirm.linked')}</p>
|
||||||
|
<p class="muted">{t('confirm.close')}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.confirm {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: min(94vw, 360px);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.ok {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.err {
|
||||||
|
color: var(--danger, #c0392b);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user