Stage 11: account linking & merge (email + Telegram Login Widget) (#12)
This commit was merged in pull request #12.
This commit is contained in:
+4
-1
@@ -26,7 +26,10 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
|
||||
```
|
||||
|
||||
`GATEWAY_URL` overrides the dev proxy target; `VITE_GATEWAY_URL` sets the runtime
|
||||
gateway origin for a packaged (non-proxied) build.
|
||||
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` (Stage 11)
|
||||
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
|
||||
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
||||
share-to-Telegram deep-link base (Stage 9).
|
||||
|
||||
## How it talks to the gateway
|
||||
|
||||
|
||||
@@ -139,6 +139,34 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa
|
||||
await expect(save).toBeEnabled();
|
||||
});
|
||||
|
||||
test('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.locator('.burger').first().click();
|
||||
await page.getByRole('button', { name: /Profile/ }).click();
|
||||
|
||||
// The linking section is shown to everyone (guests upgrade by linking).
|
||||
await expect(page.getByRole('heading', { name: 'Link an account' })).toBeVisible();
|
||||
// An address containing "merge" stands in (in the mock) for one already owned by
|
||||
// another account, so the confirm step reveals a required merge.
|
||||
await page.locator('.emailbox input[type="email"]').fill('merge@example.com');
|
||||
await page.getByRole('button', { name: 'Send code' }).click();
|
||||
await page.locator('.emailbox .codein').fill('123456');
|
||||
await page.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
// The reveal happens only after the code, and names the other account.
|
||||
await expect(page.getByText('Merge accounts?')).toBeVisible();
|
||||
await expect(page.getByText(/Ann/)).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Merge' }).click();
|
||||
await expect(page.getByText('Merge accounts?')).toBeHidden();
|
||||
});
|
||||
|
||||
test('link account: the Telegram web sign-in control is offered in a browser', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.locator('.burger').first().click();
|
||||
await page.getByRole('button', { name: /Profile/ }).click();
|
||||
await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('chat send and nudge are icon buttons', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.getByRole('button', { name: /Ann/ }).click();
|
||||
|
||||
@@ -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';
|
||||
|
||||
+13
-13
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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 ---
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Тема',
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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()));
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user