Stage 11: account linking & merge (email + Telegram Login Widget) (#12)
This commit was merged in pull request #12.
This commit is contained in:
@@ -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()));
|
||||
|
||||
Reference in New Issue
Block a user