Stage 11: account linking & merge (email + Telegram Login Widget) (#12)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 18s

This commit was merged in pull request #12.
This commit is contained in:
2026-06-04 09:18:17 +00:00
parent 3a640a17a4
commit 01485d8fc6
68 changed files with 3331 additions and 369 deletions
+4 -2
View File
@@ -9,8 +9,6 @@ export { ChatPostRequest } from './scrabblefb/chat-post-request.js';
export { CheckWordRequest } from './scrabblefb/check-word-request.js';
export { ComplaintRequest } from './scrabblefb/complaint-request.js';
export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js';
export { EmailBindRequest } from './scrabblefb/email-bind-request.js';
export { EmailConfirmRequest } from './scrabblefb/email-confirm-request.js';
export { EmailLoginRequest } from './scrabblefb/email-login-request.js';
export { EmailRequestRequest } from './scrabblefb/email-request-request.js';
export { EnqueueRequest } from './scrabblefb/enqueue-request.js';
@@ -32,6 +30,10 @@ export { Invitation } from './scrabblefb/invitation.js';
export { InvitationActionRequest } from './scrabblefb/invitation-action-request.js';
export { InvitationInvitee } from './scrabblefb/invitation-invitee.js';
export { InvitationList } from './scrabblefb/invitation-list.js';
export { LinkEmailConfirm } from './scrabblefb/link-email-confirm.js';
export { LinkEmailRequest } from './scrabblefb/link-email-request.js';
export { LinkResult } from './scrabblefb/link-result.js';
export { LinkTelegramRequest } from './scrabblefb/link-telegram-request.js';
export { MatchFoundEvent } from './scrabblefb/match-found-event.js';
export { MatchResult } from './scrabblefb/match-result.js';
export { MoveRecord } from './scrabblefb/move-record.js';
@@ -2,22 +2,22 @@
import * as flatbuffers from 'flatbuffers';
export class EmailConfirmRequest {
export class LinkEmailConfirm {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):EmailConfirmRequest {
__init(i:number, bb:flatbuffers.ByteBuffer):LinkEmailConfirm {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsEmailConfirmRequest(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmRequest):EmailConfirmRequest {
return (obj || new EmailConfirmRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
static getRootAsLinkEmailConfirm(bb:flatbuffers.ByteBuffer, obj?:LinkEmailConfirm):LinkEmailConfirm {
return (obj || new LinkEmailConfirm()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsEmailConfirmRequest(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmRequest):EmailConfirmRequest {
static getSizePrefixedRootAsLinkEmailConfirm(bb:flatbuffers.ByteBuffer, obj?:LinkEmailConfirm):LinkEmailConfirm {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new EmailConfirmRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
return (obj || new LinkEmailConfirm()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
email():string|null
@@ -34,7 +34,7 @@ code(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startEmailConfirmRequest(builder:flatbuffers.Builder) {
static startLinkEmailConfirm(builder:flatbuffers.Builder) {
builder.startObject(2);
}
@@ -46,15 +46,15 @@ static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, codeOffset, 0);
}
static endEmailConfirmRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
static endLinkEmailConfirm(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEmailConfirmRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, codeOffset:flatbuffers.Offset):flatbuffers.Offset {
EmailConfirmRequest.startEmailConfirmRequest(builder);
EmailConfirmRequest.addEmail(builder, emailOffset);
EmailConfirmRequest.addCode(builder, codeOffset);
return EmailConfirmRequest.endEmailConfirmRequest(builder);
static createLinkEmailConfirm(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, codeOffset:flatbuffers.Offset):flatbuffers.Offset {
LinkEmailConfirm.startLinkEmailConfirm(builder);
LinkEmailConfirm.addEmail(builder, emailOffset);
LinkEmailConfirm.addCode(builder, codeOffset);
return LinkEmailConfirm.endLinkEmailConfirm(builder);
}
}
@@ -2,22 +2,22 @@
import * as flatbuffers from 'flatbuffers';
export class EmailBindRequest {
export class LinkEmailRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):EmailBindRequest {
__init(i:number, bb:flatbuffers.ByteBuffer):LinkEmailRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsEmailBindRequest(bb:flatbuffers.ByteBuffer, obj?:EmailBindRequest):EmailBindRequest {
return (obj || new EmailBindRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
static getRootAsLinkEmailRequest(bb:flatbuffers.ByteBuffer, obj?:LinkEmailRequest):LinkEmailRequest {
return (obj || new LinkEmailRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsEmailBindRequest(bb:flatbuffers.ByteBuffer, obj?:EmailBindRequest):EmailBindRequest {
static getSizePrefixedRootAsLinkEmailRequest(bb:flatbuffers.ByteBuffer, obj?:LinkEmailRequest):LinkEmailRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new EmailBindRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
return (obj || new LinkEmailRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
email():string|null
@@ -27,7 +27,7 @@ email(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startEmailBindRequest(builder:flatbuffers.Builder) {
static startLinkEmailRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
@@ -35,14 +35,14 @@ static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, emailOffset, 0);
}
static endEmailBindRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
static endLinkEmailRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEmailBindRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset):flatbuffers.Offset {
EmailBindRequest.startEmailBindRequest(builder);
EmailBindRequest.addEmail(builder, emailOffset);
return EmailBindRequest.endEmailBindRequest(builder);
static createLinkEmailRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset):flatbuffers.Offset {
LinkEmailRequest.startLinkEmailRequest(builder);
LinkEmailRequest.addEmail(builder, emailOffset);
return LinkEmailRequest.endLinkEmailRequest(builder);
}
}
+95
View File
@@ -0,0 +1,95 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { Session } from '../scrabblefb/session.js';
export class LinkResult {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):LinkResult {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsLinkResult(bb:flatbuffers.ByteBuffer, obj?:LinkResult):LinkResult {
return (obj || new LinkResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsLinkResult(bb:flatbuffers.ByteBuffer, obj?:LinkResult):LinkResult {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new LinkResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
status():string|null
status(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
status(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;
}
secondaryUserId():string|null
secondaryUserId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
secondaryUserId(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;
}
secondaryDisplayName():string|null
secondaryDisplayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
secondaryDisplayName(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;
}
secondaryGames():number {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
secondaryFriends():number {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
session(obj?:Session):Session|null {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? (obj || new Session()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
static startLinkResult(builder:flatbuffers.Builder) {
builder.startObject(6);
}
static addStatus(builder:flatbuffers.Builder, statusOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, statusOffset, 0);
}
static addSecondaryUserId(builder:flatbuffers.Builder, secondaryUserIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, secondaryUserIdOffset, 0);
}
static addSecondaryDisplayName(builder:flatbuffers.Builder, secondaryDisplayNameOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, secondaryDisplayNameOffset, 0);
}
static addSecondaryGames(builder:flatbuffers.Builder, secondaryGames:number) {
builder.addFieldInt32(3, secondaryGames, 0);
}
static addSecondaryFriends(builder:flatbuffers.Builder, secondaryFriends:number) {
builder.addFieldInt32(4, secondaryFriends, 0);
}
static addSession(builder:flatbuffers.Builder, sessionOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, sessionOffset, 0);
}
static endLinkResult(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class LinkTelegramRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):LinkTelegramRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsLinkTelegramRequest(bb:flatbuffers.ByteBuffer, obj?:LinkTelegramRequest):LinkTelegramRequest {
return (obj || new LinkTelegramRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsLinkTelegramRequest(bb:flatbuffers.ByteBuffer, obj?:LinkTelegramRequest):LinkTelegramRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new LinkTelegramRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
data():string|null
data(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
data(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 startLinkTelegramRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addData(builder:flatbuffers.Builder, dataOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, dataOffset, 0);
}
static endLinkTelegramRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createLinkTelegramRequest(builder:flatbuffers.Builder, dataOffset:flatbuffers.Offset):flatbuffers.Offset {
LinkTelegramRequest.startLinkTelegramRequest(builder);
LinkTelegramRequest.addData(builder, dataOffset);
return LinkTelegramRequest.endLinkTelegramRequest(builder);
}
}
+14 -1
View File
@@ -3,7 +3,7 @@
// gateway calls funnel through here so errors map to one user-facing toast and an
// expired session logs out.
import type { Profile, PushEvent, Session } from './model';
import type { LinkResult, Profile, PushEvent, Session } from './model';
import { gateway } from './gateway';
import { GatewayError } from './client';
import { navigate, router } from './router.svelte';
@@ -129,6 +129,19 @@ async function adoptSession(s: Session): Promise<void> {
void refreshNotifications();
}
/**
* applyLinkResult applies a completed account link or merge (Stage 11): it adopts a
* switched session (a guest initiator whose durable counterpart won, so the active
* account changed) or, otherwise, refreshes the current profile in place.
*/
export async function applyLinkResult(r: LinkResult): Promise<void> {
if (r.session && r.session.token) {
await adoptSession(r.session);
return;
}
app.profile = await gateway.profileGet();
}
export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
+8 -2
View File
@@ -16,6 +16,7 @@ import type {
HintResult,
Invitation,
InvitationSettings,
LinkResult,
MatchResult,
MoveResult,
Profile,
@@ -106,11 +107,16 @@ export interface GatewayClient {
// --- profile / stats / history (Stage 8) ---
profileUpdate(p: ProfileUpdate): Promise<Profile>;
emailBindRequest(email: string): Promise<void>;
emailBindConfirm(email: string, code: string): Promise<Profile>;
statsGet(): Promise<Stats>;
exportGcg(gameId: string): Promise<GcgExport>;
// --- account linking & merge (Stage 11) ---
linkEmailRequest(email: string): Promise<void>;
linkEmailConfirm(email: string, code: string): Promise<LinkResult>;
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
linkTelegram(data: string): Promise<LinkResult>;
linkTelegramMerge(data: string): Promise<LinkResult>;
// --- live stream ---
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
+44
View File
@@ -5,6 +5,7 @@ import {
decodeFriendList,
decodeGameList,
decodeInvitation,
decodeLinkResult,
decodeSession,
decodeStats,
encodeSubmitPlay,
@@ -124,6 +125,49 @@ describe('codec', () => {
expect(decodeFriendList(b.asUint8Array())).toEqual([{ accountId: 'a-1', displayName: 'Ann' }]);
});
it('decodes a merge_required LinkResult without a session', () => {
const b = new Builder(128);
const status = b.createString('merge_required');
const sid = b.createString('b-1');
const sname = b.createString('Ann');
fb.LinkResult.startLinkResult(b);
fb.LinkResult.addStatus(b, status);
fb.LinkResult.addSecondaryUserId(b, sid);
fb.LinkResult.addSecondaryDisplayName(b, sname);
fb.LinkResult.addSecondaryGames(b, 7);
fb.LinkResult.addSecondaryFriends(b, 3);
b.finish(fb.LinkResult.endLinkResult(b));
expect(decodeLinkResult(b.asUint8Array())).toEqual({
status: 'merge_required',
secondaryUserId: 'b-1',
secondaryDisplayName: 'Ann',
secondaryGames: 7,
secondaryFriends: 3,
session: null,
});
});
it('decodes a merged LinkResult carrying a switched session', () => {
const b = new Builder(128);
const token = b.createString('tok-9');
const uid = b.createString('a-1');
const dn = b.createString('Kaya');
fb.Session.startSession(b);
fb.Session.addToken(b, token);
fb.Session.addUserId(b, uid);
fb.Session.addIsGuest(b, false);
fb.Session.addDisplayName(b, dn);
const sess = fb.Session.endSession(b);
const status = b.createString('merged');
fb.LinkResult.startLinkResult(b);
fb.LinkResult.addStatus(b, status);
fb.LinkResult.addSession(b, sess);
b.finish(fb.LinkResult.endLinkResult(b));
const r = decodeLinkResult(b.asUint8Array());
expect(r.status).toBe('merged');
expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya' });
});
it('decodes an Invitation with inviter and invitees', () => {
const b = new Builder(256);
const iid = b.createString('u-1');
+35 -9
View File
@@ -19,6 +19,7 @@ import type {
Invitation,
InvitationInvitee,
InvitationSettings,
LinkResult,
MatchResult,
MoveRecord,
MoveResult,
@@ -457,22 +458,47 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
}
export function encodeEmailBind(email: string): Uint8Array {
// --- account linking & merge (Stage 11) ---
export function encodeLinkEmailRequest(email: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
fb.EmailBindRequest.startEmailBindRequest(b);
fb.EmailBindRequest.addEmail(b, e);
return finish(b, fb.EmailBindRequest.endEmailBindRequest(b));
fb.LinkEmailRequest.startLinkEmailRequest(b);
fb.LinkEmailRequest.addEmail(b, e);
return finish(b, fb.LinkEmailRequest.endLinkEmailRequest(b));
}
export function encodeEmailConfirm(email: string, code: string): Uint8Array {
export function encodeLinkEmailConfirm(email: string, code: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
const c = b.createString(code);
fb.EmailConfirmRequest.startEmailConfirmRequest(b);
fb.EmailConfirmRequest.addEmail(b, e);
fb.EmailConfirmRequest.addCode(b, c);
return finish(b, fb.EmailConfirmRequest.endEmailConfirmRequest(b));
fb.LinkEmailConfirm.startLinkEmailConfirm(b);
fb.LinkEmailConfirm.addEmail(b, e);
fb.LinkEmailConfirm.addCode(b, c);
return finish(b, fb.LinkEmailConfirm.endLinkEmailConfirm(b));
}
export function encodeLinkTelegram(data: string): Uint8Array {
const b = new Builder(256);
const d = b.createString(data);
fb.LinkTelegramRequest.startLinkTelegramRequest(b);
fb.LinkTelegramRequest.addData(b, d);
return finish(b, fb.LinkTelegramRequest.endLinkTelegramRequest(b));
}
export function decodeLinkResult(buf: Uint8Array): LinkResult {
const r = fb.LinkResult.getRootAsLinkResult(new ByteBuffer(buf));
const sess = r.session();
return {
status: (s(r.status()) || 'linked') as LinkResult['status'],
secondaryUserId: s(r.secondaryUserId()),
secondaryDisplayName: s(r.secondaryDisplayName()),
secondaryGames: r.secondaryGames(),
secondaryFriends: r.secondaryFriends(),
session: sess
? { token: s(sess.token()), userId: s(sess.userId()), isGuest: sess.isGuest(), displayName: s(sess.displayName()) }
: null,
};
}
// --- Stage 8 decoders ---
+8
View File
@@ -118,6 +118,14 @@ export const en = {
'profile.emailBound': 'Email confirmed.',
'profile.saved': 'Profile saved.',
'profile.guestLocked': 'Sign in with email to manage your profile.',
'profile.linkAccount': 'Link an account',
'profile.linkTelegram': 'Link Telegram',
'profile.linked': 'Account linked.',
'profile.merged': 'Accounts merged.',
'profile.mergeTitle': 'Merge accounts?',
'profile.mergeBody': 'This identity already belongs to “{name}” ({games} games, {friends} friends).',
'profile.mergeIrreversible': 'Merging combines both accounts into this one and cannot be undone.',
'profile.mergeConfirm': 'Merge',
'settings.title': 'Settings',
'settings.theme': 'Theme',
+8
View File
@@ -119,6 +119,14 @@ export const ru: Record<MessageKey, string> = {
'profile.emailBound': 'Почта подтверждена.',
'profile.saved': 'Профиль сохранён.',
'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.',
'profile.linkAccount': 'Привязать аккаунт',
'profile.linkTelegram': 'Привязать Telegram',
'profile.linked': 'Аккаунт привязан.',
'profile.merged': 'Аккаунты объединены.',
'profile.mergeTitle': 'Объединить аккаунты?',
'profile.mergeBody': 'Эта личность уже принадлежит «{name}» (игр: {games}, друзей: {friends}).',
'profile.mergeIrreversible': 'Объединение сольёт оба аккаунта в этот и необратимо.',
'profile.mergeConfirm': 'Объединить',
'settings.title': 'Настройки',
'settings.theme': 'Тема',
+41 -3
View File
@@ -22,6 +22,7 @@ import type {
HintResult,
Invitation,
InvitationSettings,
LinkResult,
MatchResult,
MoveResult,
Profile,
@@ -46,6 +47,18 @@ import {
type MockGame,
} from './data';
// emptyLinked is a "linked" LinkResult with no secondary summary or session switch.
function emptyLinked(): LinkResult {
return {
status: 'linked',
secondaryUserId: '',
secondaryDisplayName: '',
secondaryGames: 0,
secondaryFriends: 0,
session: null,
};
}
const POOL: Record<Variant, string> = {
english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
russian: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
@@ -415,10 +428,35 @@ export class MockGateway implements GatewayClient {
Object.assign(this.profile, p);
return { ...this.profile };
}
async emailBindRequest(_email: string): Promise<void> {}
async emailBindConfirm(_email: string, _code: string): Promise<Profile> {
// --- account linking & merge (Stage 11) ---
async linkEmailRequest(_email: string): Promise<void> {}
async linkEmailConfirm(email: string, _code: string): Promise<LinkResult> {
// An address containing "merge" stands in for one already owned by another
// account, so the mock can drive the irreversible-merge confirmation.
if (email.includes('merge')) {
return {
status: 'merge_required',
secondaryUserId: 'mock-secondary',
secondaryDisplayName: 'Ann',
secondaryGames: 7,
secondaryFriends: 3,
session: null,
};
}
this.profile.isGuest = false;
return { ...this.profile };
return emptyLinked();
}
async linkEmailMerge(_email: string, _code: string): Promise<LinkResult> {
this.profile.isGuest = false;
return { ...emptyLinked(), status: 'merged' };
}
async linkTelegram(_data: string): Promise<LinkResult> {
this.profile.isGuest = false;
return emptyLinked();
}
async linkTelegramMerge(_data: string): Promise<LinkResult> {
this.profile.isGuest = false;
return { ...emptyLinked(), status: 'merged' };
}
async statsGet(): Promise<Stats> {
return { ...this.stats };
+14
View File
@@ -188,6 +188,20 @@ export interface Session {
displayName: string;
}
// LinkResult is the outcome of an account link/merge step (Stage 11). status is
// 'linked' (bound to the current account), 'merge_required' (the identity belongs to
// another account — the secondary* fields summarise it for the irreversible
// confirmation) or 'merged'. session is set only when the active account switched
// (a guest initiator whose durable counterpart won); the client adopts it.
export interface LinkResult {
status: 'linked' | 'merge_required' | 'merged';
secondaryUserId: string;
secondaryDisplayName: string;
secondaryGames: number;
secondaryFriends: number;
session: Session | null;
}
export interface MatchResult {
matched: boolean;
game?: GameView;
+93
View File
@@ -65,3 +65,96 @@ export function onTelegramPath(): boolean {
if (typeof location === 'undefined') return false;
return location.pathname.startsWith('/telegram/');
}
// --- Login Widget (web sign-in for account linking, Stage 11) ---
// The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to
// attach a Telegram identity to an existing account from a browser; inside the Mini
// App the session is already a Telegram identity. It needs the bot id (numeric,
// VITE_TELEGRAM_BOT_ID) and, in production, the site domain registered with BotFather
// (/setdomain) — without that Telegram refuses to render. The connector validates the
// returned data (HMAC under SHA-256(bot_token)).
const widgetScriptSrc = 'https://telegram.org/js/telegram-widget.js?22';
interface telegramAuthUser {
id: number;
first_name?: string;
last_name?: string;
username?: string;
photo_url?: string;
auth_date: number;
hash: string;
}
interface telegramLoginSDK {
auth(opts: { bot_id: string; request_access?: string }, cb: (user: telegramAuthUser | false) => void): void;
}
function isMock(): boolean {
return import.meta.env.MODE === 'mock';
}
function botID(): string {
return (import.meta.env.VITE_TELEGRAM_BOT_ID as string | undefined) ?? '';
}
/**
* loginWidgetAvailable reports whether the "Link Telegram" control should be shown:
* not already inside the Mini App, and either the mock build or a configured bot id.
*/
export function loginWidgetAvailable(): boolean {
if (insideTelegram()) return false;
return isMock() || botID() !== '';
}
let widgetLoad: Promise<void> | null = null;
function loadWidget(): Promise<void> {
if (typeof document === 'undefined') return Promise.reject(new Error('telegram: no document'));
const sdk = (window as unknown as { Telegram?: { Login?: telegramLoginSDK } }).Telegram?.Login;
if (sdk) return Promise.resolve();
if (!widgetLoad) {
widgetLoad = new Promise<void>((resolve, reject) => {
const s = document.createElement('script');
s.src = widgetScriptSrc;
s.async = true;
s.onload = () => resolve();
s.onerror = () => reject(new Error('telegram: widget load failed'));
document.head.appendChild(s);
});
}
return widgetLoad;
}
/**
* requestTelegramLogin drives the Login Widget popup and resolves with the auth data
* serialized as a URL query string (id=...&auth_date=...&hash=...) — the form the
* connector validates — or null when the user cancels. In the mock build it returns
* a fixed payload without loading the real widget (telegram.org is blocked in tests).
*/
export async function requestTelegramLogin(): Promise<string | null> {
if (isMock()) {
return `id=42&first_name=Telegram&auth_date=${Math.floor(Date.now() / 1000)}&hash=mock`;
}
await loadWidget();
const login = (window as unknown as { Telegram?: { Login?: telegramLoginSDK } }).Telegram?.Login;
if (!login) throw new Error('telegram: login unavailable');
const user = await new Promise<telegramAuthUser | false>((resolve) => {
login.auth({ bot_id: botID(), request_access: 'write' }, resolve);
});
if (!user) return null;
return serializeTelegramAuth(user);
}
function serializeTelegramAuth(u: telegramAuthUser): string {
const params = new URLSearchParams();
params.set('id', String(u.id));
if (u.first_name) params.set('first_name', u.first_name);
if (u.last_name) params.set('last_name', u.last_name);
if (u.username) params.set('username', u.username);
if (u.photo_url) params.set('photo_url', u.photo_url);
params.set('auth_date', String(u.auth_date));
params.set('hash', u.hash);
return params.toString();
}
+13 -4
View File
@@ -176,11 +176,20 @@ export function createTransport(baseUrl: string): GatewayClient {
async profileUpdate(p) {
return codec.decodeProfile(await exec('profile.update', codec.encodeUpdateProfile(p)));
},
async emailBindRequest(email) {
await exec('email.bind.request', codec.encodeEmailBind(email));
async linkEmailRequest(email) {
await exec('link.email.request', codec.encodeLinkEmailRequest(email));
},
async emailBindConfirm(email, code) {
return codec.decodeProfile(await exec('email.bind.confirm', codec.encodeEmailConfirm(email, code)));
async linkEmailConfirm(email, code) {
return codec.decodeLinkResult(await exec('link.email.confirm', codec.encodeLinkEmailConfirm(email, code)));
},
async linkEmailMerge(email, code) {
return codec.decodeLinkResult(await exec('link.email.merge', codec.encodeLinkEmailConfirm(email, code)));
},
async linkTelegram(data) {
return codec.decodeLinkResult(await exec('link.telegram.confirm', codec.encodeLinkTelegram(data)));
},
async linkTelegramMerge(data) {
return codec.decodeLinkResult(await exec('link.telegram.merge', codec.encodeLinkTelegram(data)));
},
async statsGet() {
return codec.decodeStats(await exec('stats.get', codec.empty()));
+99 -33
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import Modal from '../components/Modal.svelte';
import Screen from '../components/Screen.svelte';
import { app, handleError, logout, showToast } from '../lib/app.svelte';
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
import { t } from '../lib/i18n/index.svelte';
import {
awayDurationOk,
@@ -29,6 +31,11 @@
let emailInput = $state('');
let codeInput = $state('');
let emailSent = $state(false);
// A pending irreversible merge surfaced after the code/widget was verified; the
// dialog confirms it. tgData holds the Telegram widget payload for the merge step.
let pendingMerge = $state<null | { kind: 'email' | 'telegram'; name: string; games: number; friends: number }>(null);
let tgData = '';
const telegramLinkable = loginWidgetAvailable();
function defaultTz(): string {
const b = browserOffset();
@@ -79,10 +86,16 @@
}
}
function resetEmail() {
emailSent = false;
emailInput = '';
codeInput = '';
}
async function requestEmail() {
if (!emailOk) return;
try {
await gateway.emailBindRequest(emailInput.trim());
await gateway.linkEmailRequest(emailInput.trim());
emailSent = true;
showToast(t('profile.emailSent', { email: emailInput.trim() }));
} catch (e) {
@@ -92,11 +105,48 @@
async function confirmEmail() {
try {
app.profile = await gateway.emailBindConfirm(emailInput.trim(), codeInput.trim());
emailSent = false;
emailInput = '';
codeInput = '';
showToast(t('profile.emailBound'));
const r = await gateway.linkEmailConfirm(emailInput.trim(), codeInput.trim());
if (r.status === 'merge_required') {
pendingMerge = { kind: 'email', name: r.secondaryDisplayName, games: r.secondaryGames, friends: r.secondaryFriends };
return;
}
await applyLinkResult(r);
resetEmail();
showToast(t('profile.linked'));
} catch (e) {
handleError(e);
}
}
async function linkTelegram() {
try {
const data = await requestTelegramLogin();
if (!data) return;
const r = await gateway.linkTelegram(data);
if (r.status === 'merge_required') {
tgData = data;
pendingMerge = { kind: 'telegram', name: r.secondaryDisplayName, games: r.secondaryGames, friends: r.secondaryFriends };
return;
}
await applyLinkResult(r);
showToast(t('profile.linked'));
} catch (e) {
handleError(e);
}
}
async function confirmMerge() {
if (!pendingMerge) return;
try {
const r =
pendingMerge.kind === 'email'
? await gateway.linkEmailMerge(emailInput.trim(), codeInput.trim())
: await gateway.linkTelegramMerge(tgData);
await applyLinkResult(r);
pendingMerge = null;
tgData = '';
resetEmail();
showToast(t('profile.merged'));
} catch (e) {
handleError(e);
}
@@ -169,38 +219,54 @@
<p class="muted">{t('profile.guestLocked')}</p>
{:else}
<button class="btn" onclick={startEdit}>{t('profile.edit')}</button>
<section class="emailbox">
<h3>{t('profile.bindEmail')}</h3>
{#if !emailSent}
<div class="addrow">
<input
class:invalid={emailInput.length > 0 && !emailOk}
bind:value={emailInput}
placeholder={t('login.emailPlaceholder')}
type="email"
/>
<button class="ghost" onclick={requestEmail} disabled={!emailOk}>{t('login.sendCode')}</button>
</div>
{:else}
<div class="addrow">
<input
class="codein"
bind:value={codeInput}
placeholder={t('profile.emailCode')}
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={confirmEmail}>{t('common.ok')}</button>
</div>
{/if}
</section>
{/if}
<!-- Linking & merge (Stage 11). Shown to everyone, including guests, who
upgrade by binding their first identity. -->
<section class="emailbox">
<h3>{t('profile.linkAccount')}</h3>
{#if !emailSent}
<div class="addrow">
<input
class:invalid={emailInput.length > 0 && !emailOk}
bind:value={emailInput}
placeholder={t('login.emailPlaceholder')}
type="email"
/>
<button class="ghost" onclick={requestEmail} disabled={!emailOk}>{t('login.sendCode')}</button>
</div>
{:else}
<div class="addrow">
<input
class="codein"
bind:value={codeInput}
placeholder={t('profile.emailCode')}
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={confirmEmail}>{t('common.ok')}</button>
</div>
{/if}
{#if telegramLinkable}
<button class="ghost tg" onclick={linkTelegram}>{t('profile.linkTelegram')}</button>
{/if}
</section>
{/if}
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
{/if}
</div>
{#if pendingMerge}
<Modal title={t('profile.mergeTitle')} onclose={() => (pendingMerge = null)}>
<p>{t('profile.mergeBody', { name: pendingMerge.name, games: pendingMerge.games, friends: pendingMerge.friends })}</p>
<p class="warn">{t('profile.mergeIrreversible')}</p>
<div class="addrow end">
<button class="ghost" onclick={() => (pendingMerge = null)}>{t('common.cancel')}</button>
<button class="btn" onclick={confirmMerge}>{t('profile.mergeConfirm')}</button>
</div>
</Modal>
{/if}
</Screen>
<style>