// Login flow over the bridgev2 v3 login API. The bridge owns the step // machine (provisioninglogin.go); we render whatever step it returns: // // phone flow: user_input(phone_number) → user_input(2fa_code) // → [user_input(password)] → complete // qr flow: display_and_wait(qr) —long-poll→ rotated qr | password | complete // // `.incorrect` step_id variants (wrong code / wrong password) keep the login // process alive — the form stays open with an inline error. Every OTHER step // error kills the process server-side (doLoginStep deletes it), so the flow // resets; for the phone form we keep the typed number on screen and // transparently start a fresh process on resubmit. import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; import qrcodeGenerator from 'qrcode-generator'; // `/min` metadata (~15 KB gzip) covers all country calling codes + length // validation. Sufficient for «is this a plausible phone number?» — the // bridge does the authoritative validation server-side. import { AsYouType, isValidPhoneNumber } from 'libphonenumber-js/min'; import { ProvisioningClient, ProvisioningError, TG_FLOW_PHONE, TG_FLOW_QR, isNotFound, type LoginStep, } from './provisioning'; import { describeApiError } from './errors'; import { EyeBlindIcon, EyeIcon } from './ui'; import type { T } from './i18n'; // --- Flow state ------------------------------------------------------------- export type LoginFormKind = 'phone' | 'code' | 'password'; export type LoginUi = | { kind: 'idle' } | { kind: 'starting'; flow: 'phone' | 'qr' } | { kind: 'form'; form: LoginFormKind; loginId: string; stepId: string; fieldId: string; busy: boolean; error?: string; /** The server-side login process died (its errors are terminal) — the * next submit transparently starts a fresh process. Phone form only. */ needsRestart?: boolean; } | { kind: 'qr'; loginId: string; stepId: string; url: string }; type LoginFlowCallbacks = { /** A `complete` step landed — refresh whoami and celebrate. */ onComplete: () => void; /** Terminal flow error to surface outside the (now closed) form. */ onError: (message: string) => void; }; export type LoginFlow = { ui: LoginUi; start: (flow: 'phone' | 'qr') => void; submit: (value: string) => void; cancel: () => void; /** Phone-submit cooldown deadline (SMS rate-limit guard), null when idle. */ phoneCooldownEnd: number | null; /** Last phone number the user typed (display-formatted) — survives * cancel→reopen so retrying during the SMS cooldown doesn't force * retyping. */ lastPhone: string; rememberPhone: (value: string) => void; }; // Telegram throttles repeat SMS hard. 60 s matches Telegram Desktop's own // "Resend code" lockout. Armed only when the bridge confirms it dispatched a // code (the submit resolved into the code step). const PHONE_COOLDOWN_MS = 60_000; // --- Wait-loop resilience ---------------------------------------------------- // The QR long-poll dies whenever Android freezes the backgrounded WebView — // and scanning the QR with the Telegram app on the SAME phone requires // leaving Vojo, which kills the TCP connection within ~15 s. Since mautrix // v0.28.1 the login process lives server-side with a 30-minute TTL // (provisioninglogin.go: Wait runs on login.Ctx, not the request context), // so a dropped poll is reattachable: the loop retries transport-shaped // failures with backoff and wakes early when the tab returns to the // foreground. const WAIT_RETRY_BASE_MS = 2_000; const WAIT_RETRY_MAX_MS = 15_000; // Transport-shaped failures: fetch network death (TypeError), an abort that // is NOT ours (the frozen WebView tearing the connection down, or the // per-attempt poll timeout), or an errcode-LESS 5xx — a proxy-shaped // 502/503/504 from Caddy with a non-JSON body. Anything carrying an errcode // is a RespError the bridge itself wrote and is an authoritative verdict on // the login — NOT retryable. Notably M_BAD_STATE (a background QR scan on a // 2FA account advanced the step machine to the password step while we were // frozen) and the framework's LOGIN_TIMED_OUT ship as HTTP 500, so a bare // status check would retry a dead step id forever. const isTransientWaitError = (err: unknown): boolean => { if (err instanceof ProvisioningError) { return err.httpStatus >= 500 && err.errcode === undefined; } return err instanceof Error && (err.name === 'TypeError' || err.name === 'AbortError'); }; // Backoff delay that resolves early when the document becomes visible again // (snappy reattach after returning from the Telegram app) or when the loop // is aborted (the caller re-checks the signal and exits). const waitBeforeRetry = (ms: number, signal: AbortSignal): Promise => new Promise((resolve) => { let timer: number | null = null; let cleanup = () => {}; const done = () => { cleanup(); resolve(); }; const onVisible = () => { if (document.visibilityState === 'visible') done(); }; cleanup = () => { if (timer !== null) window.clearTimeout(timer); document.removeEventListener('visibilitychange', onVisible); signal.removeEventListener('abort', done); }; timer = window.setTimeout(done, ms); document.addEventListener('visibilitychange', onVisible); signal.addEventListener('abort', done); }); export const useLoginFlow = ( client: ProvisioningClient, t: T, callbacks: LoginFlowCallbacks ): LoginFlow => { const [ui, setUi] = useState({ kind: 'idle' }); const [phoneCooldownEnd, setPhoneCooldownEnd] = useState(null); // Latest-wins guards for async work. Bumping the generation invalidates // every in-flight continuation (QR long-poll loop, submit handlers); // aborting the controller actually cancels the poll's fetch. const generation = useRef(0); const waitAbort = useRef(null); const lastPhoneRef = useRef(''); const callbacksRef = useRef(callbacks); callbacksRef.current = callbacks; useEffect( () => () => { generation.current += 1; waitAbort.current?.abort(); }, [] ); return useMemo(() => { const formFromField = (fieldType: string | undefined): LoginFormKind | null => { if (fieldType === 'phone_number') return 'phone'; if (fieldType === '2fa_code') return 'code'; if (fieldType === 'password') return 'password'; return null; }; const runQrWaitLoop = (loginId: string, firstStepId: string, gen: number): void => { waitAbort.current?.abort(); const controller = new AbortController(); waitAbort.current = controller; void (async () => { let stepId = firstStepId; let retryDelayMs = WAIT_RETRY_BASE_MS; for (;;) { let step: LoginStep; try { // Blocks server-side until the QR token rotates (~30 s), the // scan succeeds, or the login dies. step = await client.loginWait(loginId, stepId, controller.signal); retryDelayMs = WAIT_RETRY_BASE_MS; } catch (err) { if (gen !== generation.current || controller.signal.aborted) return; if (isTransientWaitError(err)) { // The panel stays up; the next attempt either reattaches to // the still-alive server-side step or gets an authoritative // 4xx and ends the flow honestly. await waitBeforeRetry(retryDelayMs, controller.signal); if (gen !== generation.current || controller.signal.aborted) return; retryDelayMs = Math.min(retryDelayMs * 2, WAIT_RETRY_MAX_MS); continue; } // A 404 (or ALREADY_FINISHED) can mean two opposite things: the // window expired, or the login COMPLETED while the WebView was // frozen — a finished login is removed from the registry, so a // late re-poll can't tell the difference. whoami is the // authority on which way it went. const gone = isNotFound(err) || (err instanceof ProvisioningError && err.errcode === 'FI.MAU.BRIDGE.LOGIN_ALREADY_FINISHED'); if (gone) { // The probe itself retries transport blips (a completed login // must not be reported as «timed out» because one whoami GET // hit a dead network); any authoritative answer breaks out. let probeDelayMs = WAIT_RETRY_BASE_MS; for (;;) { try { const whoami = await client.whoami(); if (gen !== generation.current || controller.signal.aborted) return; if (whoami.logins && whoami.logins.length > 0) { setUi({ kind: 'idle' }); callbacksRef.current.onComplete(); return; } break; // authoritative «no login» — the window really expired } catch (probeErr) { if (gen !== generation.current || controller.signal.aborted) return; if (!isTransientWaitError(probeErr)) break; await waitBeforeRetry(probeDelayMs, controller.signal); if (gen !== generation.current || controller.signal.aborted) return; probeDelayMs = Math.min(probeDelayMs * 2, WAIT_RETRY_MAX_MS); } } } else { // The login may still be alive server-side (e.g. M_BAD_STATE // desync after a background 2FA scan) — free the 30-minute // slot. Best-effort; the cancel route exists on this bridge. void client.loginCancel(loginId).catch(() => undefined); } setUi({ kind: 'idle' }); callbacksRef.current.onError( gone ? t('error.login-timeout') : describeApiError(err, t) ); return; } if (gen !== generation.current) return; if (step.type === 'display_and_wait') { const data = step.display_and_wait?.data; if (step.display_and_wait?.type === 'qr' && data) { stepId = step.step_id; setUi({ kind: 'qr', loginId, stepId: step.step_id, url: data }); continue; } setUi({ kind: 'idle' }); callbacksRef.current.onError(t('error.generic', { reason: step.step_id })); return; } applyStep(step, gen); return; } })(); }; const applyStep = (step: LoginStep, gen: number): void => { if (gen !== generation.current) return; if (step.type === 'complete') { setUi({ kind: 'idle' }); callbacksRef.current.onComplete(); return; } if (step.type === 'user_input') { const field = step.user_input?.fields?.[0]; const form = formFromField(field?.type); if (!field || !form) { setUi({ kind: 'idle' }); callbacksRef.current.onError(t('error.generic', { reason: step.step_id })); void client.loginCancel(step.login_id).catch(() => undefined); return; } const error = step.step_id.endsWith('.incorrect') ? t(form === 'password' ? 'error.password-incorrect' : 'error.code-incorrect') : undefined; setUi({ kind: 'form', form, loginId: step.login_id, stepId: step.step_id, fieldId: field.id, busy: false, error, }); return; } if (step.type === 'display_and_wait') { const data = step.display_and_wait?.data; if (step.display_and_wait?.type === 'qr' && data) { setUi({ kind: 'qr', loginId: step.login_id, stepId: step.step_id, url: data }); runQrWaitLoop(step.login_id, step.step_id, gen); return; } } // cookies / unknown display types — nothing the Telegram connector // ships today. Bail out coherently instead of rendering nothing. setUi({ kind: 'idle' }); callbacksRef.current.onError(t('error.generic', { reason: step.step_id })); void client.loginCancel(step.login_id).catch(() => undefined); }; const start = (flow: 'phone' | 'qr'): void => { generation.current += 1; const gen = generation.current; setUi({ kind: 'starting', flow }); void client .loginStart(flow === 'phone' ? TG_FLOW_PHONE : TG_FLOW_QR) .then((step) => { if (gen !== generation.current) { // User cancelled while the start was in flight — don't leave an // orphaned login process on the bridge for its 30-minute TTL. void client.loginCancel(step.login_id).catch(() => undefined); return; } applyStep(step, gen); }) .catch((err) => { if (gen !== generation.current) return; setUi({ kind: 'idle' }); callbacksRef.current.onError(describeApiError(err, t)); }); }; const submit = (value: string): void => { if (ui.kind !== 'form' || ui.busy) return; const snapshot = ui; const gen = generation.current; setUi({ ...snapshot, busy: true, error: undefined }); void (async () => { try { let step: LoginStep; if (snapshot.needsRestart && snapshot.form === 'phone') { // Previous process died on a terminal error — restart // transparently so «fix the typo and resubmit» just works. const fresh = await client.loginStart(TG_FLOW_PHONE); if (gen !== generation.current) { // Cancelled while the restart was in flight — don't go on to // submit the number and trigger an SMS for a flow nobody is // looking at. void client.loginCancel(fresh.login_id).catch(() => undefined); return; } const freshField = fresh.user_input?.fields?.[0]; if (fresh.type !== 'user_input' || !freshField) { applyStep(fresh, gen); return; } step = await client.loginSubmitInput(fresh.login_id, fresh.step_id, { [freshField.id]: value, }); } else { step = await client.loginSubmitInput(snapshot.loginId, snapshot.stepId, { [snapshot.fieldId]: value, }); } // The phone-number submit resolving into a user_input step means // Telegram dispatched a code — arm the SMS-resend cooldown BEFORE // the stale-generation check: a cancel that raced the submit // doesn't un-send the SMS, and the cooldown must survive // cancel→retry (that's its whole job). if (snapshot.form === 'phone' && step.type === 'user_input') { setPhoneCooldownEnd(Date.now() + PHONE_COOLDOWN_MS); } if (gen !== generation.current) return; applyStep(step, gen); } catch (err) { if (gen !== generation.current) return; const message = describeApiError(err, t); if (snapshot.form === 'phone') { setUi({ ...snapshot, busy: false, error: message, needsRestart: true }); } else { // Code/password context is gone server-side; reopening the // form would submit into a deleted process. setUi({ kind: 'idle' }); callbacksRef.current.onError(message); } } })(); }; const cancel = (): void => { generation.current += 1; waitAbort.current?.abort(); // 'starting' has no loginId yet — the stale-generation check in // start().then() cancels the just-created process when it lands. // phoneCooldownEnd deliberately SURVIVES cancel: it guards Telegram's // SMS rate limit, and a cancel→restart loop must not reset it (the // submit button labels the remaining wait, so this reads as intended). const loginId = ui.kind === 'form' || ui.kind === 'qr' ? ui.loginId : undefined; if (loginId) void client.loginCancel(loginId).catch(() => undefined); setUi({ kind: 'idle' }); }; return { ui, start, submit, cancel, phoneCooldownEnd, lastPhone: lastPhoneRef.current, rememberPhone: (value: string) => { lastPhoneRef.current = value; }, }; // `ui` MUST stay in the deps: submit/cancel read it via closure capture // (not refs), so dropping it would freeze them on a stale snapshot. // Regenerating the flow object per ui change is cheap — nothing // downstream memoizes on its identity. }, [client, t, ui, phoneCooldownEnd]); }; // --- Shared form helpers ------------------------------------------------------ // Hint shown when a submit round-trip is slow (Telegram-side latency). const STILL_WAITING_DELAY_MS = 8_000; const useStillWaiting = (active: boolean): boolean => { const [show, setShow] = useState(false); useEffect(() => { setShow(false); if (!active) return undefined; const timer = window.setTimeout(() => setShow(true), STILL_WAITING_DELAY_MS); return () => window.clearTimeout(timer); }, [active]); return show; }; // Tick once per second while a future timestamp is still in the future. export const useCooldownSeconds = (until: number | null): number => { const compute = () => (until ? Math.max(0, Math.ceil((until - Date.now()) / 1000)) : 0); const [seconds, setSeconds] = useState(compute); useEffect(() => { if (!until) { setSeconds(0); return undefined; } setSeconds(compute()); const timer = window.setInterval(() => { const next = Math.max(0, Math.ceil((until - Date.now()) / 1000)); setSeconds(next); if (next <= 0) window.clearInterval(timer); }, 1000); return () => window.clearInterval(timer); // `compute` is referentially fresh each render but captures `until`; // the effect only needs to re-run when `until` itself changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [until]); return seconds; }; const useCountdown = (initialSeconds: number): number => { const [remaining, setRemaining] = useState(initialSeconds); useEffect(() => { if (remaining <= 0) return undefined; const timer = window.setTimeout(() => setRemaining((s) => Math.max(0, s - 1)), 1000); return () => window.clearTimeout(timer); }, [remaining]); return remaining; }; // --- Phone form --------------------------------------------------------------- // Minimum digit count before we'd dare call a number «invalid» — below this // the user is still typing the country prefix. const PHONE_MIN_DIGITS_FOR_VALIDATION = 7; const phoneToE164 = (raw: string): string => { const cleaned = raw.replace(/[^\d+]/g, ''); if (cleaned.length === 0) return ''; return cleaned.startsWith('+') ? cleaned : `+${cleaned}`; }; // AsYouType is stateful — use a fresh instance per call so mid-string edits // (paste, backspace) can't desync the formatter from the input value. type PhoneFormat = { formatted: string; country: string | undefined }; const formatPhoneInput = (raw: string): PhoneFormat => { const e164 = phoneToE164(raw); if (!e164) return { formatted: '', country: undefined }; const formatter = new AsYouType(); const formatted = formatter.input(e164); return { formatted, country: formatter.getCountry() }; }; // ISO 3166-1 alpha-2 → regional-indicator emoji. 'RU' → 🇷🇺. const countryToFlagEmoji = (cc: string | undefined): string => { if (!cc || cc.length !== 2) return ''; const codePoints = cc .toUpperCase() .split('') .map((c) => 127397 + c.charCodeAt(0)); return String.fromCodePoint(...codePoints); }; type FormProps = { flow: LoginFlow; t: T; }; export const PhoneForm = ({ flow, t }: FormProps) => { const ui = flow.ui.kind === 'form' ? flow.ui : null; // Pre-fill the number the user typed in a previous attempt — a cancel // during the SMS cooldown shouldn't cost them the input. const [value, setValue] = useState(() => flow.lastPhone); const [country, setCountry] = useState(() => flow.lastPhone ? formatPhoneInput(flow.lastPhone).country : undefined ); const inputRef = useRef(null); const busy = ui?.busy ?? false; const stillWaiting = useStillWaiting(busy); const cooldownSeconds = useCooldownSeconds(flow.phoneCooldownEnd); const inCooldown = cooldownSeconds > 0; useEffect(() => { inputRef.current?.focus(); }, []); const e164 = phoneToE164(value); const digitsCount = e164.replace('+', '').length; const hasEnoughDigits = digitsCount >= PHONE_MIN_DIGITS_FOR_VALIDATION; // Soft hint, not a hard gate — libphonenumber metadata lags newly // allocated pools and the bridge has the authoritative word. const showInvalidHint = hasEnoughDigits && !isValidPhoneNumber(e164); const onSubmit = (event: Event) => { event.preventDefault(); if (!e164 || busy || inCooldown || !hasEnoughDigits) return; flow.rememberPhone(value); flow.submit(e164); }; const submitLabel = inCooldown ? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) }) : t('auth-card.phone.submit'); const flagEmoji = countryToFlagEmoji(country); const error = ui?.error; return (
{t('auth-card.phone.title')}
{flagEmoji ? ( ) : null} { const raw = (e.currentTarget as HTMLInputElement).value; const next = formatPhoneInput(raw); setValue(next.formatted); setCountry(next.country); }} disabled={busy} />
{t('auth-card.phone.hint')}
{showInvalidHint && !error ? (
{t('auth-card.phone.invalid')}
) : null} {error ?
{error}
: null} {busy && stillWaiting ? (
{t('auth-card.waiting-hint')}
) : null}
); }; // --- Code form ---------------------------------------------------------------- const CODE_COUNTDOWN_SECONDS = 30; export const CodeForm = ({ flow, t }: FormProps) => { const ui = flow.ui.kind === 'form' ? flow.ui : null; const [value, setValue] = useState(''); const inputRef = useRef(null); const busy = ui?.busy ?? false; const stillWaiting = useStillWaiting(busy); const countdownSeconds = useCountdown(CODE_COUNTDOWN_SECONDS); const error = ui?.error; useEffect(() => { inputRef.current?.focus(); }, []); const onSubmit = (event: Event) => { event.preventDefault(); const trimmed = value.trim(); if (!trimmed || busy) return; setValue(''); flow.submit(trimmed); }; return (
{t('auth-card.code.title')}
setValue((e.currentTarget as HTMLInputElement).value)} disabled={busy} />
{error ?
{error}
: null} {/* SMS countdown is suppressed while submitting (the bridge-latency * hint takes over) AND after a wrong-code error — by then the code * already arrived, so «код придёт через N сек» / «не пришло» copy * would contradict the error line right above it. */} {!busy && !error && (countdownSeconds > 0 ? (
{t('auth-card.code.countdown', { seconds: String(countdownSeconds) })}
) : (
{t('auth-card.code.countdown-done')}
))} {busy && stillWaiting ? (
{t('auth-card.waiting-hint')}
) : null}
); }; // --- Password form -------------------------------------------------------------- export const PasswordForm = ({ flow, t }: FormProps) => { const ui = flow.ui.kind === 'form' ? flow.ui : null; const [value, setValue] = useState(''); const [reveal, setReveal] = useState(false); const inputRef = useRef(null); const busy = ui?.busy ?? false; const stillWaiting = useStillWaiting(busy); const error = ui?.error; useEffect(() => { inputRef.current?.focus(); }, []); const onSubmit = (event: Event) => { event.preventDefault(); if (!value || busy) return; // Drop the plaintext from component state BEFORE the async submit so it // doesn't linger in the DOM input while the request is in flight. const password = value; setValue(''); flow.submit(password); }; return (
{t('auth-card.password.title')}
{t('auth-card.password.hint')}
setValue((e.currentTarget as HTMLInputElement).value)} disabled={busy} />
{error ?
{error}
: null} {busy && stillWaiting ? (
{t('auth-card.waiting-hint')}
) : null}
); }; // --- QR panel --------------------------------------------------------------- // bridgev2's server-side login deadline for the telegram connector is 10 // minutes (LoginTimeout, loginqr.go). Soft countdown — at zero we surface a // retry hint; the server kills the process on its own. const QR_TIMEOUT_MS = 10 * 60 * 1000; // Error-correction level M: more glare-resilient than L, smaller modules // than Q — matches Telegram Desktop's own QR-login screen. const buildQrModules = (data: string): boolean[][] | null => { if (!data) return null; try { const qr = qrcodeGenerator(0, 'M'); qr.addData(data); qr.make(); const count = qr.getModuleCount(); const matrix: boolean[][] = []; for (let r = 0; r < count; r += 1) { const row: boolean[] = []; for (let c = 0; c < count; c += 1) { row.push(qr.isDark(r, c)); } matrix.push(row); } return matrix; } catch { return null; } }; // Render the QR matrix as s inside an SVG. No dangerouslySetInnerHTML, // no external rendering service — the `tg://login?token=...` URL IS the login // secret and must never leave the iframe. type QrSvgProps = { matrix: boolean[][]; pixelSize: number; ariaLabel: string }; const QrSvg = ({ matrix, pixelSize, ariaLabel }: QrSvgProps) => { const count = matrix.length; const margin = 4; const totalUnits = count + margin * 2; const cellPx = pixelSize / totalUnits; const rects: ComponentChildren[] = []; for (let r = 0; r < count; r += 1) { for (let c = 0; c < count; c += 1) { if (!matrix[r][c]) continue; rects.push( ); } } return ( {rects} ); }; type QrPanelProps = { url: string; t: T; onCancel: () => void; }; export const QrPanel = ({ url, t, onCancel }: QrPanelProps) => { // First-shown timestamp survives QR rotations — the component stays // mounted while only `url` changes, and the countdown tracks the WHOLE // login window, not the validity of one displayed token. const [firstShownAt] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now()); useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); }, []); const matrix = useMemo(() => buildQrModules(url), [url]); const elapsed = now - firstShownAt; const remainingSeconds = Math.max(0, Math.ceil((QR_TIMEOUT_MS - elapsed) / 1000)); const expired = elapsed >= QR_TIMEOUT_MS; return (
{t('auth-card.qr.title')}
{t('auth-card.qr.hint')}
{matrix ? ( // The aria-label describes the PURPOSE of the QR, not its contents — // the URL itself is the login secret. ) : (
{t('auth-card.qr.preparing')}
)}
{!expired ? (
{t('auth-card.qr.countdown', { minutes: String(Math.floor(remainingSeconds / 60)), seconds: String(remainingSeconds % 60).padStart(2, '0'), })}
) : (
{t('auth-card.qr.expired')}
)}
  1. {t('auth-card.qr.step-1')}
  2. {t('auth-card.qr.step-2')}
  3. {t('auth-card.qr.step-3')}
); };