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
+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();
}