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
|
||||
// it on first contact with browserTZ — the client's detected "±HH:MM" UTC offset —
|
||||
// seeded into its time zone and language seeded from the client's UI language. Like
|
||||
// seeded into its time zone, language seeded from the client's UI language, and its
|
||||
// display name seeded from the email's local part (so it is not left nameless). Like
|
||||
// ProvisionByIdentity it is race-safe and leaves an existing account untouched, so a
|
||||
// returning user's saved zone and language are never overwritten. The email account is
|
||||
// returning user's saved zone, language and name are never overwritten. The email account is
|
||||
// created here (the code-request step), not at the later login, so this is where its
|
||||
// zone and language are seeded. It is created flagged is_guest with an unconfirmed
|
||||
// email identity: an abandoned, never-confirmed login is then reaped like any guest,
|
||||
// freeing the reserved address, and confirming the code clears the guest flag.
|
||||
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ, language string) (Account, error) {
|
||||
return s.provision(ctx, KindEmail, externalID, provisionSeed{
|
||||
displayName: emailDisplayName(externalID),
|
||||
timeZone: seedZone(browserTZ),
|
||||
preferredLanguage: supportedLanguage(language),
|
||||
isGuest: true,
|
||||
})
|
||||
}
|
||||
|
||||
// emailDisplayName derives a display name from an email address — the local part
|
||||
// before '@', trimmed and capped to the column width — so a new email account is not
|
||||
// left nameless. It is only the first-contact seed; the user can rename it later.
|
||||
func emailDisplayName(email string) string {
|
||||
local, _, _ := strings.Cut(email, "@")
|
||||
local = strings.TrimSpace(local)
|
||||
if r := []rune(local); len(r) > maxDisplayName {
|
||||
local = strings.TrimRight(string(r[:maxDisplayName]), " ")
|
||||
}
|
||||
return local
|
||||
}
|
||||
|
||||
// supportedLanguage returns code normalised to a supported UI language ("en" or
|
||||
// "ru"), or "" when it maps to neither, so a new account keeps the 'en' default. It
|
||||
// accepts region-tagged codes ("ru-RU").
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
crand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -26,6 +27,11 @@ const (
|
||||
emailCodeTTL = 15 * time.Minute
|
||||
// emailCodeMaxAttempts caps wrong-code submissions before a code is dead.
|
||||
emailCodeMaxAttempts = 5
|
||||
// linkTokenBytes is the entropy of a confirm deeplink token: 256 bits.
|
||||
linkTokenBytes = 32
|
||||
// emailConfirmPath is the SPA route the one-tap confirm deeplink opens (the token
|
||||
// is appended). The SPA is served under /app/ behind a hash router.
|
||||
emailConfirmPath = "/app/#/confirm/"
|
||||
)
|
||||
|
||||
// Confirmation purposes recorded on a pending confirm-code row. They select what
|
||||
@@ -92,18 +98,23 @@ func (s *EmailService) allowSend(email string) bool {
|
||||
return s.limiter == nil || s.limiter.Allow(email)
|
||||
}
|
||||
|
||||
// issueCode generates a fresh confirm-code for (accountID, email), replaces any prior
|
||||
// pending confirmation, and mails the branded code in locale; purpose selects the
|
||||
// email wording. Only the SHA-256 hash of the code is stored.
|
||||
// issueCode generates a fresh confirm-code and one-tap deeplink token for (accountID,
|
||||
// email), replaces any prior pending confirmation, and mails the branded code in
|
||||
// locale; purpose selects the email wording and what verifying does. Only the SHA-256
|
||||
// hashes of the code and token are stored.
|
||||
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string) error {
|
||||
code, codeHash, err := generateCode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, s.now().Add(emailCodeTTL)); err != nil {
|
||||
token, tokenHash, err := generateLinkToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := renderConfirmationEmail(purpose, code, "", s.baseURL, locale)
|
||||
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := renderConfirmationEmail(purpose, code, s.confirmURL(token, locale), s.baseURL, locale)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -111,6 +122,16 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email
|
||||
return s.mailer.Send(ctx, msg)
|
||||
}
|
||||
|
||||
// confirmURL builds the absolute one-tap confirm deeplink for token in locale, or ""
|
||||
// when no public base URL is configured. The locale rides the fragment as ?lang so the
|
||||
// confirm screen (opened in a browser with no session) renders in the email's language.
|
||||
func (s *EmailService) confirmURL(token, locale string) string {
|
||||
if s.baseURL == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token + "?lang=" + normalizeLocale(locale)
|
||||
}
|
||||
|
||||
// accountLocale returns the account's preferred UI language for localising email,
|
||||
// defaulting to "en" when the account cannot be loaded.
|
||||
func (s *EmailService) accountLocale(ctx context.Context, accountID uuid.UUID) string {
|
||||
@@ -241,6 +262,75 @@ func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (A
|
||||
return s.store.GetByID(ctx, acc.ID)
|
||||
}
|
||||
|
||||
// LinkConfirmation is the outcome of confirming a one-tap deeplink token: what the
|
||||
// transport layer must finish. Purpose is the pending row's purpose. For a login,
|
||||
// Account is the account to sign in. For a link, Account is the account the email was
|
||||
// (or would be) attached to; NeedsMerge is set when another account (MergeOwner)
|
||||
// already owns the address, so the caller drives the interactive merge instead of a
|
||||
// plain link — the token is left unconsumed for that merge step.
|
||||
type LinkConfirmation struct {
|
||||
Purpose string
|
||||
Account uuid.UUID
|
||||
NeedsMerge bool
|
||||
MergeOwner uuid.UUID
|
||||
}
|
||||
|
||||
// IsLogin reports whether the confirmation is a login (the caller mints a session)
|
||||
// rather than a link (attach the identity, or drive a merge).
|
||||
func (r LinkConfirmation) IsLogin() bool { return r.Purpose == purposeLogin }
|
||||
|
||||
// ConfirmByToken verifies a one-tap deeplink token and performs its purpose. A login
|
||||
// confirms the email identity, clears the guest flag and returns the account to sign
|
||||
// in. A link attaches the confirmed email to the pending account when the address is
|
||||
// free, or reports NeedsMerge when another account already owns it (leaving the token
|
||||
// live so the caller's merge step can re-verify). It returns ErrNoPendingCode when the
|
||||
// token matches no live confirmation and ErrCodeExpired when it has lapsed. The token
|
||||
// is high-entropy, so there is no wrong-attempt counter.
|
||||
func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkConfirmation, error) {
|
||||
pend, err := s.store.pendingByTokenHash(ctx, hashCode(token))
|
||||
if err != nil {
|
||||
return LinkConfirmation{}, err
|
||||
}
|
||||
if s.now().After(pend.expiresAt) {
|
||||
return LinkConfirmation{}, ErrCodeExpired
|
||||
}
|
||||
switch pend.purpose {
|
||||
case purposeLogin:
|
||||
if err := s.store.confirmEmailLogin(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
|
||||
return LinkConfirmation{}, err
|
||||
}
|
||||
if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
|
||||
return LinkConfirmation{}, err
|
||||
}
|
||||
return LinkConfirmation{Purpose: purposeLogin, Account: pend.accountID}, nil
|
||||
case purposeLink:
|
||||
owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email)
|
||||
if err != nil {
|
||||
return LinkConfirmation{}, err
|
||||
}
|
||||
if ok {
|
||||
if owner == pend.accountID {
|
||||
if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil {
|
||||
return LinkConfirmation{}, err
|
||||
}
|
||||
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
|
||||
}
|
||||
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID, NeedsMerge: true, MergeOwner: owner}, nil
|
||||
}
|
||||
if err := s.store.confirmEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
|
||||
return LinkConfirmation{}, err
|
||||
}
|
||||
// Binding the first email promotes a guest to a durable account, matching the
|
||||
// code-based link flow (which clears the guest flag in the link service).
|
||||
if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
|
||||
return LinkConfirmation{}, err
|
||||
}
|
||||
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
|
||||
default:
|
||||
return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose)
|
||||
}
|
||||
}
|
||||
|
||||
// emailConfirmation is a pending confirm-code row in domain form.
|
||||
type emailConfirmation struct {
|
||||
id uuid.UUID
|
||||
@@ -271,7 +361,7 @@ func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.U
|
||||
|
||||
// replacePendingConfirmation clears any pending code for (accountID, email) and
|
||||
// inserts a fresh one, inside one transaction.
|
||||
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash string, expiresAt time.Time) error {
|
||||
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash, linkTokenHash, purpose string, expiresAt time.Time) error {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return fmt.Errorf("account: new confirmation id: %w", err)
|
||||
@@ -288,7 +378,8 @@ func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.U
|
||||
ins := table.EmailConfirmations.INSERT(
|
||||
table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID,
|
||||
table.EmailConfirmations.Email, table.EmailConfirmations.CodeHash, table.EmailConfirmations.ExpiresAt,
|
||||
).VALUES(id, accountID, email, codeHash, expiresAt)
|
||||
table.EmailConfirmations.LinkTokenHash, table.EmailConfirmations.Purpose,
|
||||
).VALUES(id, accountID, email, codeHash, expiresAt, linkTokenHash, purpose)
|
||||
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert confirmation: %w", err)
|
||||
}
|
||||
@@ -321,6 +412,54 @@ func (s *Store) latestPendingConfirmation(ctx context.Context, accountID uuid.UU
|
||||
}, nil
|
||||
}
|
||||
|
||||
// pendingConfirmation is a pending confirm-code row loaded by its deeplink token, in
|
||||
// domain form.
|
||||
type pendingConfirmation struct {
|
||||
id uuid.UUID
|
||||
accountID uuid.UUID
|
||||
email string
|
||||
purpose string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// pendingByTokenHash loads the unconsumed confirmation whose deeplink token hashes to
|
||||
// tokenHash, or ErrNoPendingCode. The high-entropy token needs no attempt counter, so
|
||||
// a partial-unique index guarantees at most one match.
|
||||
func (s *Store) pendingByTokenHash(ctx context.Context, tokenHash string) (pendingConfirmation, error) {
|
||||
stmt := postgres.SELECT(table.EmailConfirmations.AllColumns).
|
||||
FROM(table.EmailConfirmations).
|
||||
WHERE(
|
||||
table.EmailConfirmations.LinkTokenHash.EQ(postgres.String(tokenHash)).
|
||||
AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
|
||||
).LIMIT(1)
|
||||
var row model.EmailConfirmations
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return pendingConfirmation{}, ErrNoPendingCode
|
||||
}
|
||||
return pendingConfirmation{}, fmt.Errorf("account: load confirmation by token: %w", err)
|
||||
}
|
||||
return pendingConfirmation{
|
||||
id: row.ConfirmationID,
|
||||
accountID: row.AccountID,
|
||||
email: row.Email,
|
||||
purpose: row.Purpose,
|
||||
expiresAt: row.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// consumeConfirmation marks a confirmation consumed without writing an identity, used
|
||||
// for the idempotent already-linked deeplink path.
|
||||
func (s *Store) consumeConfirmation(ctx context.Context, id uuid.UUID, now time.Time) error {
|
||||
upd := table.EmailConfirmations.UPDATE(table.EmailConfirmations.ConsumedAt).
|
||||
SET(postgres.TimestampzT(now)).
|
||||
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(id)))
|
||||
if _, err := upd.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("account: consume confirmation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bumpConfirmationAttempts increments a code's wrong-attempt counter by one.
|
||||
func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error {
|
||||
stmt := table.EmailConfirmations.
|
||||
@@ -414,6 +553,18 @@ func generateCode() (code, hash string, err error) {
|
||||
return code, hashCode(code), nil
|
||||
}
|
||||
|
||||
// generateLinkToken returns a fresh opaque one-tap confirm deeplink token (URL-safe
|
||||
// base64, 256-bit) and its hex SHA-256 hash. Only the hash is stored; the token
|
||||
// travels only in the emailed link, mirroring the session-token model.
|
||||
func generateLinkToken() (token, hash string, err error) {
|
||||
buf := make([]byte, linkTokenBytes)
|
||||
if _, err := crand.Read(buf); err != nil {
|
||||
return "", "", fmt.Errorf("account: generate link token: %w", err)
|
||||
}
|
||||
token = base64.RawURLEncoding.EncodeToString(buf)
|
||||
return token, hashCode(token), nil
|
||||
}
|
||||
|
||||
// hashCode returns the hex-encoded SHA-256 of a confirm-code.
|
||||
func hashCode(code string) string {
|
||||
sum := sha256.Sum256([]byte(code))
|
||||
|
||||
@@ -2,6 +2,7 @@ package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -16,6 +17,52 @@ import (
|
||||
// belongs to another account; the caller turns it into a merge.
|
||||
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
|
||||
|
||||
// ErrLastIdentity is returned when removing an identity would leave the account with
|
||||
// none, making it unreachable after logout. The admin email-erase refuses it.
|
||||
var ErrLastIdentity = errors.New("account: cannot remove the last identity")
|
||||
|
||||
// RemoveEmailIdentity deletes the account's email identity and any pending confirmations
|
||||
// for it, freeing the address for reuse. It refuses when the email is the account's only
|
||||
// identity (ErrLastIdentity) — that would leave the account unreachable — and returns
|
||||
// ErrNotFound when the account has no email identity. It backs the admin console's
|
||||
// "erase email" action.
|
||||
func (s *Store) RemoveEmailIdentity(ctx context.Context, accountID uuid.UUID) error {
|
||||
ids, err := s.Identities(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasEmail, others := false, 0
|
||||
for _, id := range ids {
|
||||
if id.Kind == KindEmail {
|
||||
hasEmail = true
|
||||
} else {
|
||||
others++
|
||||
}
|
||||
}
|
||||
if !hasEmail {
|
||||
return ErrNotFound
|
||||
}
|
||||
if others == 0 {
|
||||
return ErrLastIdentity
|
||||
}
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
delID := table.Identities.DELETE().WHERE(
|
||||
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
|
||||
)
|
||||
if _, err := delID.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("account: delete email identity %s: %w", accountID, err)
|
||||
}
|
||||
delConf := table.EmailConfirmations.DELETE().WHERE(
|
||||
table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)),
|
||||
)
|
||||
if _, err := delConf.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("account: delete email confirmations %s: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RequestLinkCode issues and mails a confirm-code for email to accountID,
|
||||
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
||||
// (taken or already-confirmed): possession of the address is the authorization for
|
||||
|
||||
@@ -28,11 +28,13 @@ type UserListItem struct {
|
||||
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
|
||||
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
|
||||
// one char) matched case-insensitively against the display name / any identity's external
|
||||
// id. An empty mask means no filter on that field.
|
||||
// id; EmailExact is a strict (exact) match against an account's email identity. An empty
|
||||
// value means no filter on that field.
|
||||
type UserFilter struct {
|
||||
Robots bool
|
||||
NameMask string
|
||||
ExternalIDMask string
|
||||
EmailExact string
|
||||
}
|
||||
|
||||
// robotExists is the correlated subquery testing whether account a is a robot.
|
||||
@@ -63,6 +65,10 @@ func userListWhere(f UserFilter) (string, []any) {
|
||||
args = append(args, ext)
|
||||
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args))
|
||||
}
|
||||
if email := strings.ToLower(strings.TrimSpace(f.EmailExact)); email != "" {
|
||||
args = append(args, email)
|
||||
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'email' AND i.external_id = $%d)`, len(args))
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,11 @@
|
||||
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</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 class="panel"><h2>Friends</h2>
|
||||
<table class="list">
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
|
||||
<input name="name" value="{{.NameMask}}" placeholder="display name 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>
|
||||
</form>
|
||||
<table class="list">
|
||||
|
||||
@@ -62,6 +62,7 @@ type UsersView struct {
|
||||
Robots bool
|
||||
NameMask string
|
||||
ExternalIDMask string
|
||||
EmailExact string
|
||||
FilterQuery template.URL
|
||||
}
|
||||
|
||||
@@ -161,7 +162,9 @@ type UserDetailView struct {
|
||||
HasStats bool
|
||||
Stats StatsRow
|
||||
Identities []IdentityRow
|
||||
Games []GameRow
|
||||
// HasEmail gates the "Erase email" action; set when the account carries an email identity.
|
||||
HasEmail bool
|
||||
Games []GameRow
|
||||
// TelegramID and VKID are the account's platform external ids (empty when absent).
|
||||
// TelegramID gates the "Send Telegram message" operator action; VKID surfaces the VK
|
||||
// user id with a link to the VK profile (there is no VK messaging to drive).
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
// 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()}
|
||||
}
|
||||
|
||||
// ProfileChanged is a payload-free "re-fetch your profile" signal to userID, emitted
|
||||
// when the viewer's own account changed out of band — an email confirmed through the
|
||||
// one-tap deeplink opened in another browser.
|
||||
func ProfileChanged(userID uuid.UUID) Intent {
|
||||
return Notification(userID, NotifyProfile)
|
||||
}
|
||||
|
||||
// NotificationAccount builds a lobby notification of one of the friend_* kinds carrying the
|
||||
// account it concerns (the requester, the new friend or the decliner), so the client updates its
|
||||
// requests/friends lists and the in-game "add friend" state without a refetch.
|
||||
|
||||
@@ -69,6 +69,10 @@ const (
|
||||
// it re-fetches profile.get to show or hide the banner. It carries no payload (the
|
||||
// banner set rides the profile response). In-app only.
|
||||
NotifyBanner = "banner"
|
||||
// NotifyProfile tells the client that the viewer's own profile changed out of band
|
||||
// (e.g. an email was confirmed via the one-tap deeplink opened in another browser),
|
||||
// so it re-fetches profile.get. It carries no payload. In-app only.
|
||||
NotifyProfile = "profile"
|
||||
// NotifyUserBlocked confirms to the blocker that a per-user block took effect,
|
||||
// carrying the blocked account, so every one of the blocker's sessions updates the
|
||||
// in-game block/add-friend controls and the struck name in place. It is delivered
|
||||
|
||||
@@ -21,4 +21,6 @@ type EmailConfirmations struct {
|
||||
Attempts int16
|
||||
ConsumedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
Purpose string
|
||||
LinkTokenHash *string
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ type emailConfirmationsTable struct {
|
||||
Attempts postgres.ColumnInteger
|
||||
ConsumedAt postgres.ColumnTimestampz
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
Purpose postgres.ColumnString
|
||||
LinkTokenHash postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -74,9 +76,11 @@ func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailCo
|
||||
AttemptsColumn = postgres.IntegerColumn("attempts")
|
||||
ConsumedAtColumn = postgres.TimestampzColumn("consumed_at")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn}
|
||||
PurposeColumn = postgres.StringColumn("purpose")
|
||||
LinkTokenHashColumn = postgres.StringColumn("link_token_hash")
|
||||
allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn, PurposeColumn, LinkTokenHashColumn}
|
||||
mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn, PurposeColumn, LinkTokenHashColumn}
|
||||
defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn, PurposeColumn}
|
||||
)
|
||||
|
||||
return emailConfirmationsTable{
|
||||
@@ -91,6 +95,8 @@ func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailCo
|
||||
Attempts: AttemptsColumn,
|
||||
ConsumedAt: ConsumedAtColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
Purpose: PurposeColumn,
|
||||
LinkTokenHash: LinkTokenHashColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -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/email/request", s.handleEmailRequest)
|
||||
in.POST("/sessions/email/login", s.handleEmailLogin)
|
||||
in.POST("/sessions/email/confirm-link", s.handleEmailConfirmLink)
|
||||
in.POST("/sessions/resolve", s.handleResolveSession)
|
||||
in.POST("/sessions/revoke", s.handleRevokeSession)
|
||||
// Out-of-app push routing for the platform side-service: the
|
||||
|
||||
@@ -61,6 +61,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
|
||||
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
||||
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
|
||||
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
|
||||
gm.GET("/reasons", s.consoleReasons)
|
||||
gm.POST("/reasons", s.consoleCreateReason)
|
||||
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
|
||||
@@ -134,6 +135,7 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
||||
Robots: c.Query("kind") == "robots",
|
||||
NameMask: c.Query("name"),
|
||||
ExternalIDMask: c.Query("ext"),
|
||||
EmailExact: c.Query("email"),
|
||||
}
|
||||
total, _ := s.accounts.CountUsers(ctx, filter)
|
||||
items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize)
|
||||
@@ -151,9 +153,13 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
||||
if strings.TrimSpace(filter.ExternalIDMask) != "" {
|
||||
q.Set("ext", filter.ExternalIDMask)
|
||||
}
|
||||
if strings.TrimSpace(filter.EmailExact) != "" {
|
||||
q.Set("email", filter.EmailExact)
|
||||
}
|
||||
view := adminconsole.UsersView{
|
||||
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
||||
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
|
||||
EmailExact: filter.EmailExact,
|
||||
FilterQuery: template.URL(q.Encode()),
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(items))
|
||||
@@ -356,6 +362,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
}
|
||||
if ids, err := s.accounts.Identities(ctx, id); err == nil {
|
||||
for _, idn := range ids {
|
||||
if idn.Kind == account.KindEmail {
|
||||
view.HasEmail = true
|
||||
}
|
||||
view.Identities = append(view.Identities, adminconsole.IdentityRow{Kind: idn.Kind, ExternalID: idn.ExternalID, Confirmed: idn.Confirmed, CreatedAt: fmtTime(idn.CreatedAt)})
|
||||
}
|
||||
}
|
||||
@@ -951,6 +960,25 @@ func (s *Server) consoleGrantHints(c *gin.Context) {
|
||||
s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back)
|
||||
}
|
||||
|
||||
// consoleRemoveEmail deletes the account's bound email identity (and any pending
|
||||
// confirmations), freeing the address. It refuses to remove the account's only
|
||||
// identity, which would leave it unreachable.
|
||||
func (s *Server) consoleRemoveEmail(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/users/" + id.String()
|
||||
switch err := s.accounts.RemoveEmailIdentity(c.Request.Context(), id); {
|
||||
case errors.Is(err, account.ErrLastIdentity):
|
||||
s.renderConsoleMessage(c, "Can't remove", "the email is this account's only identity — removing it would leave the account unreachable", back)
|
||||
case err != nil:
|
||||
s.consoleError(c, err)
|
||||
default:
|
||||
s.renderConsoleMessage(c, "Removed", "the email identity was erased and the address freed", back)
|
||||
}
|
||||
}
|
||||
|
||||
// consoleBlockUser manually blocks an account: it records the suspension (permanent or until a
|
||||
// parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active
|
||||
// games, removing them from matchmaking. The block takes effect on the player's next request.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
|
||||
@@ -212,6 +213,56 @@ func (s *Server) handleEmailLogin(c *gin.Context) {
|
||||
s.mintSession(c, acc)
|
||||
}
|
||||
|
||||
// confirmLinkResponse is the outcome of a one-tap deeplink confirmation. For a login,
|
||||
// Session carries the freshly minted credential (the deeplink page signs in with it);
|
||||
// for a link, Status is "confirmed" or "merge_required" (the app completes the merge).
|
||||
type confirmLinkResponse struct {
|
||||
Purpose string `json:"purpose"`
|
||||
Status string `json:"status"`
|
||||
Session *sessionResponse `json:"session,omitempty"`
|
||||
}
|
||||
|
||||
// handleEmailConfirmLink verifies a one-tap deeplink token. A login mints a session
|
||||
// (the deeplink page signs in with it); a link attaches the confirmed email, reporting
|
||||
// "merge_required" when the address is owned by another account so the app drives the
|
||||
// interactive merge. The token, not a request session, is the authorization — the page
|
||||
// need not be signed in.
|
||||
func (s *Server) handleEmailConfirmLink(c *gin.Context) {
|
||||
var req tokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
|
||||
abortBadRequest(c, "token is required")
|
||||
return
|
||||
}
|
||||
res, err := s.emails.ConfirmByToken(c.Request.Context(), req.Token)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
if res.IsLogin() {
|
||||
acc, err := s.accounts.GetByID(c.Request.Context(), res.Account)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
sess := sessionResponseFor(token, acc)
|
||||
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "login", Status: "confirmed", Session: &sess})
|
||||
return
|
||||
}
|
||||
if res.NeedsMerge {
|
||||
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: "merge_required"})
|
||||
return
|
||||
}
|
||||
// A free link attached the email; nudge the account's in-app session(s) to re-fetch
|
||||
// the profile, so a link confirmed in another browser reflects at once.
|
||||
s.notifier.Publish(notify.ProfileChanged(res.Account))
|
||||
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: "confirmed"})
|
||||
}
|
||||
|
||||
// tokenRequest carries an opaque session token.
|
||||
type tokenRequest struct {
|
||||
Token string `json:"token"`
|
||||
|
||||
+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
|
||||
verified, attaches a confirmed email identity. Sends are throttled per recipient
|
||||
(a 60-second cooldown and a five-per-hour cap). Links in the email are built from
|
||||
a configured public base URL, never a request Host header (anti-injection). An
|
||||
a configured public base URL, never a request Host header (anti-injection). The email
|
||||
also carries a **one-tap confirm deeplink** (`/app/#/confirm/<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
|
||||
code is confirmed — so an abandoned, never-confirmed login frees its reserved
|
||||
address — with confirming clearing the flag. Accounts and identities use
|
||||
@@ -933,7 +940,10 @@ edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are o
|
||||
eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets
|
||||
`paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal),
|
||||
and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content*
|
||||
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session.
|
||||
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. The same
|
||||
mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's
|
||||
own account changed out of band (an email confirmed through the one-tap deeplink opened in another
|
||||
browser), so an open in-app session reflects it at once.
|
||||
|
||||
> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into
|
||||
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
|
||||
|
||||
+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 confirmed, so an abandoned, never-confirmed attempt is cleaned up and its address
|
||||
freed. Sends are rate-limited (a short cooldown between codes and a small hourly cap
|
||||
per address), so a mistyped address or an impatient tap cannot flood an inbox.
|
||||
per address), so a mistyped address or an impatient tap cannot flood an inbox. The email
|
||||
also carries a **one-tap link** that confirms the address — or signs the player straight
|
||||
in — in a single tap; opening it in another browser reflects in the app at once, and a
|
||||
mail scanner that merely fetches the link cannot reach it — the token rides the URL
|
||||
fragment, which is never sent to the server.
|
||||
Telegram runs a **single bot**: every player uses
|
||||
the same bot, and all of its chat and out-of-app notifications are written in the
|
||||
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
||||
|
||||
@@ -71,7 +71,10 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
||||
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
||||
неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая
|
||||
пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап
|
||||
не завалили почтовый ящик.
|
||||
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
|
||||
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
|
||||
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
|
||||
он едет в URL-фрагменте, который не уходит на сервер.
|
||||
Telegram держит **единого бота**: все игроки пользуются одним
|
||||
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
||||
интерфейса** самого игрока (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
|
||||
// first contact; browserTz (the client's detected "±HH:MM" UTC offset) seeds the new
|
||||
// account's time zone, since the email account is created here, not at login.
|
||||
func (c *Client) EmailRequest(ctx context.Context, email, browserTz string) error {
|
||||
func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string) error {
|
||||
return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "",
|
||||
map[string]string{"email": email, "browser_tz": browserTz}, nil)
|
||||
map[string]string{"email": email, "browser_tz": browserTz, "language": language}, nil)
|
||||
}
|
||||
|
||||
// EmailLogin verifies a login code and mints a session.
|
||||
@@ -289,6 +289,23 @@ func (c *Client) EmailLogin(ctx context.Context, email, code string) (SessionRes
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ConfirmLinkResp is the backend's outcome of a one-tap deeplink confirmation: for a
|
||||
// login Session carries the minted credential; for a link Status is "confirmed" or
|
||||
// "merge_required".
|
||||
type ConfirmLinkResp struct {
|
||||
Purpose string `json:"purpose"`
|
||||
Status string `json:"status"`
|
||||
Session *SessionResp `json:"session,omitempty"`
|
||||
}
|
||||
|
||||
// EmailConfirmLink verifies a one-tap deeplink token; the token is the authorization.
|
||||
func (c *Client) EmailConfirmLink(ctx context.Context, token string) (ConfirmLinkResp, error) {
|
||||
var out ConfirmLinkResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/confirm-link", "", "",
|
||||
map[string]string{"token": token}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ResolveSession maps a token to its account id and guest flag (gateway
|
||||
// session-cache miss). The guest flag lets the edge gate guest-forbidden ops.
|
||||
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, error) {
|
||||
|
||||
@@ -37,6 +37,35 @@ func encodeAck(ok bool) []byte {
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeConfirmLinkResult builds an EmailConfirmLinkResult payload, embedding the
|
||||
// minted Session for a login. All strings and the nested Session table are built
|
||||
// before the result table is opened.
|
||||
func encodeConfirmLinkResult(r backendclient.ConfirmLinkResp) []byte {
|
||||
b := flatbuffers.NewBuilder(160)
|
||||
purpose := b.CreateString(r.Purpose)
|
||||
status := b.CreateString(r.Status)
|
||||
var session flatbuffers.UOffsetT
|
||||
if r.Session != nil {
|
||||
token := b.CreateString(r.Session.Token)
|
||||
uid := b.CreateString(r.Session.UserID)
|
||||
name := b.CreateString(r.Session.DisplayName)
|
||||
fb.SessionStart(b)
|
||||
fb.SessionAddToken(b, token)
|
||||
fb.SessionAddUserId(b, uid)
|
||||
fb.SessionAddIsGuest(b, r.Session.IsGuest)
|
||||
fb.SessionAddDisplayName(b, name)
|
||||
session = fb.SessionEnd(b)
|
||||
}
|
||||
fb.EmailConfirmLinkResultStart(b)
|
||||
fb.EmailConfirmLinkResultAddPurpose(b, purpose)
|
||||
fb.EmailConfirmLinkResultAddStatus(b, status)
|
||||
if r.Session != nil {
|
||||
fb.EmailConfirmLinkResultAddSession(b, session)
|
||||
}
|
||||
b.Finish(fb.EmailConfirmLinkResultEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeProfile builds a Profile payload, including the advertising-banner block
|
||||
// when the backend marked the viewer eligible.
|
||||
func encodeProfile(p backendclient.ProfileResp) []byte {
|
||||
|
||||
@@ -19,37 +19,38 @@ import (
|
||||
|
||||
// Message types in the vertical slice.
|
||||
const (
|
||||
MsgAuthTelegram = "auth.telegram"
|
||||
MsgAuthVK = "auth.vk"
|
||||
MsgAuthGuest = "auth.guest"
|
||||
MsgAuthEmailReq = "auth.email.request"
|
||||
MsgAuthEmailLogin = "auth.email.login"
|
||||
MsgProfileGet = "profile.get"
|
||||
MsgBlockStatus = "account.block_status"
|
||||
MsgGameSubmitPlay = "game.submit_play"
|
||||
MsgGameState = "game.state"
|
||||
MsgLobbyEnqueue = "lobby.enqueue"
|
||||
MsgLobbyCancel = "lobby.cancel"
|
||||
MsgLobbyPoll = "lobby.poll"
|
||||
MsgChatPost = "chat.post"
|
||||
MsgGamesList = "games.list"
|
||||
MsgGamePass = "game.pass"
|
||||
MsgGameExchange = "game.exchange"
|
||||
MsgGameResign = "game.resign"
|
||||
MsgGameHint = "game.hint"
|
||||
MsgGameEvaluate = "game.evaluate"
|
||||
MsgGameCheckWord = "game.check_word"
|
||||
MsgGameComplaint = "game.complaint"
|
||||
MsgGameHistory = "game.history"
|
||||
MsgChatList = "chat.list"
|
||||
MsgChatNudge = "chat.nudge"
|
||||
MsgChatRead = "chat.read"
|
||||
MsgDraftGet = "draft.get"
|
||||
MsgDraftSave = "draft.save"
|
||||
MsgGameHide = "game.hide"
|
||||
MsgFeedbackSubmit = "feedback.submit"
|
||||
MsgFeedbackGet = "feedback.get"
|
||||
MsgFeedbackUnread = "feedback.unread"
|
||||
MsgAuthTelegram = "auth.telegram"
|
||||
MsgAuthVK = "auth.vk"
|
||||
MsgAuthGuest = "auth.guest"
|
||||
MsgAuthEmailReq = "auth.email.request"
|
||||
MsgAuthEmailLogin = "auth.email.login"
|
||||
MsgAuthEmailConfirmLink = "auth.email.confirm_link"
|
||||
MsgProfileGet = "profile.get"
|
||||
MsgBlockStatus = "account.block_status"
|
||||
MsgGameSubmitPlay = "game.submit_play"
|
||||
MsgGameState = "game.state"
|
||||
MsgLobbyEnqueue = "lobby.enqueue"
|
||||
MsgLobbyCancel = "lobby.cancel"
|
||||
MsgLobbyPoll = "lobby.poll"
|
||||
MsgChatPost = "chat.post"
|
||||
MsgGamesList = "games.list"
|
||||
MsgGamePass = "game.pass"
|
||||
MsgGameExchange = "game.exchange"
|
||||
MsgGameResign = "game.resign"
|
||||
MsgGameHint = "game.hint"
|
||||
MsgGameEvaluate = "game.evaluate"
|
||||
MsgGameCheckWord = "game.check_word"
|
||||
MsgGameComplaint = "game.complaint"
|
||||
MsgGameHistory = "game.history"
|
||||
MsgChatList = "chat.list"
|
||||
MsgChatNudge = "chat.nudge"
|
||||
MsgChatRead = "chat.read"
|
||||
MsgDraftGet = "draft.get"
|
||||
MsgDraftSave = "draft.save"
|
||||
MsgGameHide = "game.hide"
|
||||
MsgFeedbackSubmit = "feedback.submit"
|
||||
MsgFeedbackGet = "feedback.get"
|
||||
MsgFeedbackUnread = "feedback.unread"
|
||||
)
|
||||
|
||||
// Request is one decoded Execute call.
|
||||
@@ -101,6 +102,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Op
|
||||
r.ops[MsgAuthGuest] = Op{Handler: authGuestHandler(backend)}
|
||||
r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true}
|
||||
r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend), Email: true}
|
||||
r.ops[MsgAuthEmailConfirmLink] = Op{Handler: authEmailConfirmLinkHandler(backend)}
|
||||
r.ops[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true}
|
||||
r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true}
|
||||
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
|
||||
@@ -238,7 +240,7 @@ func authGuestHandler(backend *backendclient.Client) Handler {
|
||||
func authEmailRequestHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsEmailRequestRequest(req.Payload, 0)
|
||||
if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz())); err != nil {
|
||||
if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz()), string(in.Language())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeAck(true), nil
|
||||
@@ -256,6 +258,17 @@ func authEmailLoginHandler(backend *backendclient.Client) Handler {
|
||||
}
|
||||
}
|
||||
|
||||
func authEmailConfirmLinkHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsEmailConfirmLinkRequest(req.Payload, 0)
|
||||
res, err := backend.EmailConfirmLink(ctx, string(in.Token()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeConfirmLinkResult(res), nil
|
||||
}
|
||||
}
|
||||
|
||||
func profileHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
p, err := backend.Profile(ctx, req.UserID)
|
||||
|
||||
+18
-1
@@ -132,10 +132,12 @@ table GuestLoginRequest {
|
||||
|
||||
// EmailRequestRequest asks the backend to send a login confirm-code to email. It
|
||||
// also provisions the account on first contact, so browser_tz (the detected UTC
|
||||
// offset) is seeded into its time zone here, not at the later login step.
|
||||
// offset) is seeded into its time zone here, not at the later login step; language
|
||||
// (the client's UI language) seeds the new account's language and localises the email.
|
||||
table EmailRequestRequest {
|
||||
email:string;
|
||||
browser_tz:string;
|
||||
language:string;
|
||||
}
|
||||
|
||||
// EmailLoginRequest logs in to the account owning email (provisioned at the
|
||||
@@ -145,6 +147,21 @@ table EmailLoginRequest {
|
||||
code:string;
|
||||
}
|
||||
|
||||
// EmailConfirmLinkRequest verifies a one-tap deeplink token from a confirmation
|
||||
// email. The token, not a session, is the authorization.
|
||||
table EmailConfirmLinkRequest {
|
||||
token:string;
|
||||
}
|
||||
|
||||
// EmailConfirmLinkResult is the outcome of a deeplink confirmation: purpose is
|
||||
// "login" or "link"; for a login session carries the minted credential; for a link
|
||||
// status is "confirmed" or "merge_required" (the app completes the merge).
|
||||
table EmailConfirmLinkResult {
|
||||
purpose:string;
|
||||
status:string;
|
||||
session:Session;
|
||||
}
|
||||
|
||||
// Session is the minted credential returned by every auth operation.
|
||||
table Session {
|
||||
token:string;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
func (rcv *EmailRequestRequest) Language() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EmailRequestRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(2)
|
||||
builder.StartObject(3)
|
||||
}
|
||||
func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
|
||||
@@ -66,6 +74,9 @@ func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers
|
||||
func EmailRequestRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
|
||||
}
|
||||
func EmailRequestRequestAddLanguage(builder *flatbuffers.Builder, language flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(language), 0)
|
||||
}
|
||||
func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import NewGame from './screens/NewGame.svelte';
|
||||
import SettingsHub from './screens/SettingsHub.svelte';
|
||||
import Stats from './screens/Stats.svelte';
|
||||
import Confirm from './screens/Confirm.svelte';
|
||||
import Game from './game/Game.svelte';
|
||||
import CommsHub from './game/CommsHub.svelte';
|
||||
import Feedback from './screens/Feedback.svelte';
|
||||
@@ -110,6 +111,8 @@
|
||||
<Feedback />
|
||||
{:else if router.route.name === 'stats'}
|
||||
<Stats />
|
||||
{:else if router.route.name === 'confirm'}
|
||||
<Confirm token={router.route.params.token} />
|
||||
{:else}
|
||||
<Lobby />
|
||||
{/if}
|
||||
|
||||
@@ -17,6 +17,8 @@ export { ComplaintRequest } from './scrabblefb/complaint-request.js';
|
||||
export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js';
|
||||
export { DraftRequest } from './scrabblefb/draft-request.js';
|
||||
export { DraftView } from './scrabblefb/draft-view.js';
|
||||
export { EmailConfirmLinkRequest } from './scrabblefb/email-confirm-link-request.js';
|
||||
export { EmailConfirmLinkResult } from './scrabblefb/email-confirm-link-result.js';
|
||||
export { EmailLoginRequest } from './scrabblefb/email-login-request.js';
|
||||
export { EmailRequestRequest } from './scrabblefb/email-request-request.js';
|
||||
export { EnqueueRequest } from './scrabblefb/enqueue-request.js';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
language():string|null
|
||||
language(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
language(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
static startEmailRequestRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(2);
|
||||
builder.startObject(3);
|
||||
}
|
||||
|
||||
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
|
||||
@@ -46,15 +53,20 @@ static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Off
|
||||
builder.addFieldOffset(1, browserTzOffset, 0);
|
||||
}
|
||||
|
||||
static addLanguage(builder:flatbuffers.Builder, languageOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, languageOffset, 0);
|
||||
}
|
||||
|
||||
static endEmailRequestRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, languageOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
EmailRequestRequest.startEmailRequestRequest(builder);
|
||||
EmailRequestRequest.addEmail(builder, emailOffset);
|
||||
EmailRequestRequest.addBrowserTz(builder, browserTzOffset);
|
||||
EmailRequestRequest.addLanguage(builder, languageOffset);
|
||||
return EmailRequestRequest.endEmailRequestRequest(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,6 +409,11 @@ function openStream(): void {
|
||||
if (e.sub === 'banner') {
|
||||
void refreshProfile();
|
||||
}
|
||||
// The viewer's own profile changed out of band (an email confirmed via the
|
||||
// one-tap deeplink opened in another browser): re-fetch it.
|
||||
if (e.sub === 'profile') {
|
||||
void refreshProfile();
|
||||
}
|
||||
void refreshNotifications();
|
||||
}
|
||||
},
|
||||
@@ -738,7 +743,7 @@ export async function bootstrap(): Promise<void> {
|
||||
if (saved) {
|
||||
await adoptSession(saved);
|
||||
if (router.route.name === 'login') navigate('/');
|
||||
} else if (router.route.name !== 'login') {
|
||||
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
|
||||
navigate('/login');
|
||||
}
|
||||
app.ready = true;
|
||||
@@ -915,7 +920,7 @@ export async function loginGuest(): Promise<void> {
|
||||
|
||||
export async function requestEmailCode(email: string): Promise<boolean> {
|
||||
try {
|
||||
await gateway.authEmailRequest(email);
|
||||
await gateway.authEmailRequest(email, app.locale);
|
||||
return true;
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
@@ -933,6 +938,20 @@ export async function loginEmail(email: string, code: string): Promise<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> {
|
||||
closeStream();
|
||||
resetConnection();
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
HintResult,
|
||||
Invitation,
|
||||
InvitationSettings,
|
||||
ConfirmLinkResult,
|
||||
LinkResult,
|
||||
MatchResult,
|
||||
MoveResult,
|
||||
@@ -64,8 +65,11 @@ export interface GatewayClient {
|
||||
* cosmetic seed for a brand-new account). */
|
||||
authVK(params: string, displayName: string): Promise<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>;
|
||||
/** 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 ---
|
||||
profileGet(): Promise<Profile>;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
decodeStats,
|
||||
encodeCheckWord,
|
||||
encodeEmailRequest,
|
||||
encodeEmailConfirmLink,
|
||||
encodeFeedbackSubmit,
|
||||
encodeDraftSave,
|
||||
encodeEnqueue,
|
||||
@@ -117,10 +118,16 @@ describe('codec', () => {
|
||||
expect(guest.browserTz()).toBe('-05:30');
|
||||
|
||||
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
|
||||
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00')),
|
||||
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en')),
|
||||
);
|
||||
expect(email.email()).toBe('a@example.com');
|
||||
expect(email.browserTz()).toBe('+00:00');
|
||||
expect(email.language()).toBe('en');
|
||||
|
||||
const confirm = fb.EmailConfirmLinkRequest.getRootAsEmailConfirmLinkRequest(
|
||||
new ByteBuffer(encodeEmailConfirmLink('tok-abc')),
|
||||
);
|
||||
expect(confirm.token()).toBe('tok-abc');
|
||||
|
||||
const vk = fb.VKLoginRequest.getRootAsVKLoginRequest(
|
||||
new ByteBuffer(encodeVKLogin('vk_user_id=494075&vk_ts=1&sign=abc', '+03:00', 'Иван Петров')),
|
||||
|
||||
+22
-1
@@ -31,6 +31,7 @@ import type {
|
||||
Invitation,
|
||||
InvitationInvitee,
|
||||
InvitationSettings,
|
||||
ConfirmLinkResult,
|
||||
LinkResult,
|
||||
MatchResult,
|
||||
MoveRecord,
|
||||
@@ -212,13 +213,15 @@ export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array
|
||||
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
|
||||
}
|
||||
|
||||
export function encodeEmailRequest(email: string, browserTz: string): Uint8Array {
|
||||
export function encodeEmailRequest(email: string, browserTz: string, language: string): Uint8Array {
|
||||
const b = new Builder(128);
|
||||
const e = b.createString(email);
|
||||
const tz = b.createString(browserTz);
|
||||
const l = b.createString(language);
|
||||
fb.EmailRequestRequest.startEmailRequestRequest(b);
|
||||
fb.EmailRequestRequest.addEmail(b, e);
|
||||
fb.EmailRequestRequest.addBrowserTz(b, tz);
|
||||
fb.EmailRequestRequest.addLanguage(b, l);
|
||||
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
|
||||
}
|
||||
|
||||
@@ -232,6 +235,14 @@ export function encodeEmailLogin(email: string, code: string): Uint8Array {
|
||||
return finish(b, fb.EmailLoginRequest.endEmailLoginRequest(b));
|
||||
}
|
||||
|
||||
export function encodeEmailConfirmLink(token: string): Uint8Array {
|
||||
const b = new Builder(128);
|
||||
const t = b.createString(token);
|
||||
fb.EmailConfirmLinkRequest.startEmailConfirmLinkRequest(b);
|
||||
fb.EmailConfirmLinkRequest.addToken(b, t);
|
||||
return finish(b, fb.EmailConfirmLinkRequest.endEmailConfirmLinkRequest(b));
|
||||
}
|
||||
|
||||
// --- response decoders ---
|
||||
|
||||
function s(v: string | null): string {
|
||||
@@ -730,6 +741,16 @@ export function decodeLinkResult(buf: Uint8Array): LinkResult {
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeConfirmLinkResult(buf: Uint8Array): ConfirmLinkResult {
|
||||
const r = fb.EmailConfirmLinkResult.getRootAsEmailConfirmLinkResult(new ByteBuffer(buf));
|
||||
const sess = r.session();
|
||||
return {
|
||||
purpose: (s(r.purpose()) || 'link') as ConfirmLinkResult['purpose'],
|
||||
status: (s(r.status()) || 'confirmed') as ConfirmLinkResult['status'],
|
||||
session: sess ? sessionFromTable(sess) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// --- social decoders ---
|
||||
|
||||
function decodeAccountRef(r: fb.AccountRef): AccountRef {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export const en = {
|
||||
'app.title': 'Scrabble',
|
||||
'app.brand': 'Erudit',
|
||||
'dict.loading': 'Loading…',
|
||||
'connection.connecting': 'Connecting…',
|
||||
|
||||
@@ -36,6 +37,11 @@ export const en = {
|
||||
'login.sendCode': 'Send code',
|
||||
'login.codePlaceholder': '6-digit code',
|
||||
'login.signIn': 'Sign in',
|
||||
'confirm.busy': 'Confirming your email…',
|
||||
'confirm.linked': 'Email confirmed.',
|
||||
'confirm.merge': 'Almost done — finish the merge in the app.',
|
||||
'confirm.close': 'You can close this window and return to the game.',
|
||||
'confirm.expired': 'This link is invalid or has expired. Request a new code.',
|
||||
'login.codeSent': 'We sent a code to {email}.',
|
||||
|
||||
'lobby.activeGames': 'Active games',
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MessageKey } from './en';
|
||||
|
||||
export const ru: Record<MessageKey, string> = {
|
||||
'app.title': 'Scrabble',
|
||||
'app.brand': 'Эрудит',
|
||||
'dict.loading': 'Загрузка…',
|
||||
'connection.connecting': 'Подключение…',
|
||||
|
||||
@@ -37,6 +38,11 @@ export const ru: Record<MessageKey, string> = {
|
||||
'login.sendCode': 'Отправить код',
|
||||
'login.codePlaceholder': 'Код из 6 цифр',
|
||||
'login.signIn': 'Войти',
|
||||
'confirm.busy': 'Подтверждаем почту…',
|
||||
'confirm.linked': 'Почта подтверждена.',
|
||||
'confirm.merge': 'Почти готово — завершите объединение в приложении.',
|
||||
'confirm.close': 'Можно закрыть это окно и вернуться в игру.',
|
||||
'confirm.expired': 'Ссылка недействительна или истекла. Запросите новый код.',
|
||||
'login.codeSent': 'Мы отправили код на {email}.',
|
||||
|
||||
'lobby.activeGames': 'Активные игры',
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
HintResult,
|
||||
Invitation,
|
||||
InvitationSettings,
|
||||
ConfirmLinkResult,
|
||||
LinkResult,
|
||||
MatchResult,
|
||||
MoveResult,
|
||||
@@ -149,7 +150,10 @@ export class MockGateway implements GatewayClient {
|
||||
async authGuest(): Promise<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> {
|
||||
return { ...SESSION, isGuest: false };
|
||||
}
|
||||
|
||||
@@ -355,6 +355,15 @@ export interface LinkResult {
|
||||
session: Session | null;
|
||||
}
|
||||
|
||||
// ConfirmLinkResult is the outcome of a one-tap deeplink confirmation. purpose is
|
||||
// 'login' (session carries the minted credential to sign in) or 'link' (status is
|
||||
// 'confirmed' or 'merge_required' — the app finishes any merge from its manual flow).
|
||||
export interface ConfirmLinkResult {
|
||||
purpose: 'login' | 'link';
|
||||
status: 'confirmed' | 'merge_required';
|
||||
session: Session | null;
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
matched: boolean;
|
||||
game?: GameView;
|
||||
|
||||
@@ -15,6 +15,7 @@ export type RouteName =
|
||||
| 'friends'
|
||||
| 'feedback'
|
||||
| 'stats'
|
||||
| 'confirm'
|
||||
| 'notfound';
|
||||
|
||||
export interface Route {
|
||||
@@ -58,6 +59,11 @@ export function parse(hash: string): Route {
|
||||
return { name: 'feedback', params: {} };
|
||||
case 'stats':
|
||||
return { name: 'stats', params: {} };
|
||||
case 'confirm':
|
||||
// The one-tap email deeplink: /confirm/<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:
|
||||
return { name: 'notfound', params: {} };
|
||||
}
|
||||
|
||||
@@ -101,8 +101,11 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async authGuest(locale) {
|
||||
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
|
||||
},
|
||||
async authEmailRequest(email) {
|
||||
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset()));
|
||||
async authEmailRequest(email, language) {
|
||||
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language));
|
||||
},
|
||||
async confirmEmailLink(token) {
|
||||
return codec.decodeConfirmLinkResult(await exec('auth.email.confirm_link', codec.encodeEmailConfirmLink(token)));
|
||||
},
|
||||
async authEmailLogin(email, code) {
|
||||
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code)));
|
||||
|
||||
@@ -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