Compare commits

...

11 Commits

Author SHA1 Message Date
developer 76e7916ff6 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
2026-07-03 06:58:12 +00:00
Ilia Denisov 54af644429 fix(ui): localise the confirm screen, drop the brand on the error state
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
The session-less /confirm page defaulted to English, so it showed the English
app title. Carry the recipient's language on the deeplink (?lang) and setLocale on
load so the page matches the email. Show a localised brand wordmark (Эрудит / Erudit,
matching the email) on the success state only; the invalid/expired state now shows
just the message, no header.
2026-07-03 05:54:28 +02:00
Ilia Denisov 356bf1a5ba feat(admin): search users by email + erase a bound email
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Add an exact (strict) email filter to the /users list (UserFilter.EmailExact →
a kind='email' identity match) with a search input, and an 'Erase email' action on
the user card that deletes the bound email identity and its pending confirmations,
freeing the address. It refuses to remove the account's only identity
(ErrLastIdentity), which would leave it unreachable. Integration tests for both.
2026-07-03 05:30:30 +02:00
Ilia Denisov 1dca6741f1 feat(ui): auto-confirm the email deeplink on load
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
Drop the confirm button: the /confirm screen confirms the token as soon as it
loads. The token rides the URL fragment (never sent to the server), so a plain link
prefetch cannot reach it, and the manual six-digit code is the fallback if an
aggressive scanner runs the page. Update the ARCHITECTURE/FUNCTIONAL wording
accordingly and swap the confirm.prompt/action strings for confirm.busy (en+ru).
2026-07-03 05:18:24 +02:00
Ilia Denisov 77a18a3cc1 fix(account): clear the guest flag on a deeplink email link
ConfirmByToken attached the confirmed email to the account but, on the link path,
skipped ClearGuest — so a guest who bound an email via the one-tap deeplink stayed a
guest (the code-based flow clears it in the link service). Clear the flag on a free
link too, promoting the guest to a durable account; the profile live event then
refreshes the open session. Integration test added.
2026-07-03 05:18:24 +02:00
Ilia Denisov 5804f7266e fix(account): seed the email account display name from the local part
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
An email account was provisioned with no display name (unlike Telegram/VK, which
seed one), so an email login showed an empty name. Seed it from the email's local
part (before '@', trimmed and capped to the column width) on first contact; the
user can rename it later. Only new accounts are seeded — an existing account's name
is never overwritten.
2026-07-03 05:10:22 +02:00
Ilia Denisov 65f2c87a74 docs+test(email): document the confirm deeplink + wire-contract tests
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
Document the one-tap confirm deeplink in ARCHITECTURE (§4 the login magic-link /
link confirm+profile-refresh, prefetch-safe token) and the new notify 'profile'
sub-kind (§10), and add the one-tap link to the FUNCTIONAL email story (+ru). Add
codec round-trip assertions for the EmailRequest language field and
encodeEmailConfirmLink (the mock e2e bypasses the codec).
2026-07-03 04:33:27 +02:00
Ilia Denisov 409462fc09 feat(ui): one-tap confirm deeplink screen + client language
Add the /confirm/<token> SPA route and Confirm screen: a prefetch-safe button
POSTs the token via a new confirmEmailLink RPC (client/transport/mock/codec +
ConfirmLinkResult model). A login adopts the minted session and enters the app; a
link shows confirmed / merge-in-the-app; an invalid or expired token asks for a new
code. Exempt /confirm from the no-session /login redirect. Forward the client
locale on the email request (authEmailRequest gains language → app.locale) so a
fresh web login email is localised. Handle the new 'profile' live-event sub-kind by
re-fetching the profile, so a link confirmed in another browser reflects in-app at
once. i18n en+ru.
2026-07-03 04:30:16 +02:00
Ilia Denisov 762155a55e feat(gateway): confirmEmailLink RPC + language on the email request + profile event
Add the confirm-link edge method (auth.email.confirm_link): a new
EmailConfirmLinkRequest/Result fbs table, the transcode const + handler + encoder,
and the backend-client call to the existing /sessions/email/confirm-link endpoint —
it rides Execute under the existing service prefix, so no proto/Caddy change. Add a
language field to EmailRequestRequest and forward it (the backend already seeds it).
Add the NotifyProfile sub-kind + notify.ProfileChanged, published by the confirm-link
handler on a successful link so an in-app session re-fetches its profile when the
email was confirmed in another browser. Regenerated fbs bindings (Go + TS).
2026-07-03 04:22:09 +02:00
Ilia Denisov 25d80bc31d feat(server): confirm-link endpoint for the one-tap deeplink
Add POST /internal/sessions/email/confirm-link: it verifies a deeplink token via
ConfirmByToken and, for a login, mints a session (the deeplink page signs in with
it); for a link, attaches the confirmed email and reports "confirmed" or
"merge_required" (the app drives the interactive merge). The token, not a request
session, is the authorization. Add LinkConfirmation.IsLogin() and integration tests
for the login, link and merge branches (the token is read from the mailed link).
The gateway RPC, live event and SPA route follow.
2026-07-03 04:13:48 +02:00
Ilia Denisov d5b4bba018 feat(account): restore the confirm deeplink token (domain layer)
Re-apply the deeplink backend deferred out of PR1a: migration 00006 adds
email_confirmations.purpose + link_token_hash (hand-edited jet), each issued code
now carries an opaque 256-bit token (only its SHA-256 stored), and ConfirmByToken
resolves a token to a login (confirm + clear guest) or a link (attach when free,
signal merge when owned elsewhere). issueCode now embeds the /app/#/confirm/<token>
link in the email. The confirm endpoint, gateway RPC and SPA route follow.
2026-07-03 04:07:10 +02:00
43 changed files with 1196 additions and 64 deletions
+16 -2
View File
@@ -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").
+158 -7
View File
@@ -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))
+47
View File
@@ -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
+7 -1
View File
@@ -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">
+3
View File
@@ -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,6 +162,8 @@ type UserDetailView struct {
HasStats bool HasStats bool
Stats StatsRow Stats StatsRow
Identities []IdentityRow Identities []IdentityRow
// HasEmail gates the "Erase email" action; set when the account carries an email identity.
HasEmail bool
Games []GameRow 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
@@ -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")
}
}
}
+145
View File
@@ -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")
}
}
+7
View File
@@ -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.
+4
View File
@@ -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;
+1
View File
@@ -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.
+51
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -1
View File
@@ -71,7 +71,10 @@ launch-параметрам VK (их проверяет gateway), и при пе
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная, запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая
пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап
не завалили почтовый ящик. не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
он едет в URL-фрагменте, который не уходит на сервер.
Telegram держит **единого бота**: все игроки пользуются одним Telegram держит **единого бота**: все игроки пользуются одним
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
+19 -2
View File
@@ -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) {
+29
View File
@@ -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 {
+14 -1
View File
@@ -24,6 +24,7 @@ const (
MsgAuthGuest = "auth.guest" MsgAuthGuest = "auth.guest"
MsgAuthEmailReq = "auth.email.request" MsgAuthEmailReq = "auth.email.request"
MsgAuthEmailLogin = "auth.email.login" MsgAuthEmailLogin = "auth.email.login"
MsgAuthEmailConfirmLink = "auth.email.confirm_link"
MsgProfileGet = "profile.get" MsgProfileGet = "profile.get"
MsgBlockStatus = "account.block_status" MsgBlockStatus = "account.block_status"
MsgGameSubmitPlay = "game.submit_play" MsgGameSubmitPlay = "game.submit_play"
@@ -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
View File
@@ -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()
}
+12 -1
View File
@@ -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()
} }
+3
View File
@@ -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}
+2
View File
@@ -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);
} }
} }
+21 -2
View File
@@ -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();
+5 -1
View File
@@ -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>;
+8 -1
View File
@@ -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
View File
@@ -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 {
+6
View File
@@ -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',
+6
View File
@@ -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': 'Активные игры',
+5 -1
View File
@@ -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 };
} }
+9
View File
@@ -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;
+6
View File
@@ -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: {} };
} }
+5 -2
View File
@@ -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)));
+84
View File
@@ -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>