vojo/apps/widget-telegram/src/login.tsx

859 lines
32 KiB
TypeScript

// 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<void> =>
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<LoginUi>({ kind: 'idle' });
const [phoneCooldownEnd, setPhoneCooldownEnd] = useState<number | null>(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<AbortController | null>(null);
const lastPhoneRef = useRef('');
const callbacksRef = useRef(callbacks);
callbacksRef.current = callbacks;
useEffect(
() => () => {
generation.current += 1;
waitAbort.current?.abort();
},
[]
);
return useMemo<LoginFlow>(() => {
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<string | undefined>(() =>
flow.lastPhone ? formatPhoneInput(flow.lastPhone).country : undefined
);
const inputRef = useRef<HTMLInputElement | null>(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 (
<form class={`auth-card${error ? ' error' : ''}`} onSubmit={onSubmit}>
<div class="auth-card-title">{t('auth-card.phone.title')}</div>
<label class="auth-card-hint" for="auth-phone-input">
{t('auth-card.phone.label')}
</label>
<div class="auth-card-row">
<div class={`auth-phone-shell${flagEmoji ? ' with-flag' : ''}`}>
{flagEmoji ? (
<span class="auth-phone-flag" aria-hidden="true">
{flagEmoji}
</span>
) : null}
<input
id="auth-phone-input"
ref={inputRef}
class={`auth-input${showInvalidHint ? ' warn' : ''}`}
type="tel"
autocomplete="tel"
inputmode="tel"
placeholder={t('auth-card.phone.placeholder')}
value={value}
onInput={(e) => {
const raw = (e.currentTarget as HTMLInputElement).value;
const next = formatPhoneInput(raw);
setValue(next.formatted);
setCountry(next.country);
}}
disabled={busy}
/>
</div>
<button type="submit" class="btn-primary" disabled={busy || inCooldown || !hasEnoughDigits}>
{submitLabel}
</button>
<button type="button" class="btn-text" onClick={flow.cancel}>
{t('auth-card.cancel')}
</button>
</div>
<div class="auth-card-hint">{t('auth-card.phone.hint')}</div>
{showInvalidHint && !error ? (
<div class="auth-card-warn">{t('auth-card.phone.invalid')}</div>
) : null}
{error ? <div class="auth-card-error">{error}</div> : null}
{busy && stillWaiting ? (
<div class="auth-card-waiting">{t('auth-card.waiting-hint')}</div>
) : null}
</form>
);
};
// --- 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<HTMLInputElement | null>(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 (
<form class={`auth-card${error ? ' error' : ''}`} onSubmit={onSubmit}>
<div class="auth-card-title">{t('auth-card.code.title')}</div>
<label class="auth-card-hint" for="auth-code-input">
{t('auth-card.code.label')}
</label>
<div class="auth-card-row">
<input
id="auth-code-input"
ref={inputRef}
class="auth-input code"
type="text"
autocomplete="one-time-code"
inputmode="numeric"
maxLength={6}
placeholder={t('auth-card.code.placeholder')}
value={value}
onInput={(e) => setValue((e.currentTarget as HTMLInputElement).value)}
disabled={busy}
/>
<button type="submit" class="btn-primary" disabled={busy || value.trim() === ''}>
{t('auth-card.code.submit')}
</button>
<button type="button" class="btn-text" onClick={flow.cancel}>
{t('auth-card.cancel')}
</button>
</div>
{error ? <div class="auth-card-error">{error}</div> : 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 ? (
<div class="auth-card-countdown">
{t('auth-card.code.countdown', { seconds: String(countdownSeconds) })}
</div>
) : (
<div class="auth-card-countdown expired">{t('auth-card.code.countdown-done')}</div>
))}
{busy && stillWaiting ? (
<div class="auth-card-waiting">{t('auth-card.waiting-hint')}</div>
) : null}
</form>
);
};
// --- 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<HTMLInputElement | null>(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 (
<form class={`auth-card${error ? ' error' : ''}`} onSubmit={onSubmit}>
<div class="auth-card-title">{t('auth-card.password.title')}</div>
<div class="auth-card-hint">{t('auth-card.password.hint')}</div>
<label class="auth-card-hint" for="auth-password-input">
{t('auth-card.password.label')}
</label>
<div class="auth-card-row">
<div class="auth-password-shell">
<input
id="auth-password-input"
ref={inputRef}
class="auth-input password"
type={reveal ? 'text' : 'password'}
autocomplete="current-password"
// Kill the HTML default size=20 intrinsic width so flex can
// shrink the input on narrow viewports (see styles.css notes).
size={1}
value={value}
onInput={(e) => setValue((e.currentTarget as HTMLInputElement).value)}
disabled={busy}
/>
<button
type="button"
class="auth-password-eye"
onClick={() => setReveal((v) => !v)}
aria-label={reveal ? t('auth-card.password.hide') : t('auth-card.password.show')}
aria-pressed={reveal}
aria-controls="auth-password-input"
disabled={busy}
>
{reveal ? <EyeIcon /> : <EyeBlindIcon />}
</button>
</div>
<button type="submit" class="btn-primary" disabled={busy || value === ''}>
{t('auth-card.password.submit')}
</button>
<button type="button" class="btn-text" onClick={flow.cancel}>
{t('auth-card.cancel')}
</button>
</div>
{error ? <div class="auth-card-error">{error}</div> : null}
{busy && stillWaiting ? (
<div class="auth-card-waiting">{t('auth-card.waiting-hint')}</div>
) : null}
</form>
);
};
// --- 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 <rect>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(
<rect
key={`${r}-${c}`}
x={(c + margin) * cellPx}
y={(r + margin) * cellPx}
width={cellPx + 0.5 /* +0.5 px overlap to kill subpixel gaps on Android */}
height={cellPx + 0.5}
fill="#000"
/>
);
}
}
return (
<svg
width={pixelSize}
height={pixelSize}
viewBox={`0 0 ${pixelSize} ${pixelSize}`}
role="img"
aria-label={ariaLabel}
>
{rects}
</svg>
);
};
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 (
<div class="auth-card auth-card-qr">
<div class="auth-card-title">{t('auth-card.qr.title')}</div>
<div class="auth-card-hint">{t('auth-card.qr.hint')}</div>
<div class="auth-card-qr-frame">
{matrix ? (
// The aria-label describes the PURPOSE of the QR, not its contents —
// the URL itself is the login secret.
<QrSvg matrix={matrix} pixelSize={232} ariaLabel={t('auth-card.qr.aria')} />
) : (
<div class="auth-card-qr-placeholder" role="status" aria-live="polite">
<span class="dot" />
{t('auth-card.qr.preparing')}
</div>
)}
</div>
{!expired ? (
<div class="auth-card-countdown">
{t('auth-card.qr.countdown', {
minutes: String(Math.floor(remainingSeconds / 60)),
seconds: String(remainingSeconds % 60).padStart(2, '0'),
})}
</div>
) : (
<div class="auth-card-countdown expired">{t('auth-card.qr.expired')}</div>
)}
<ol class="auth-card-qr-steps">
<li>{t('auth-card.qr.step-1')}</li>
<li>{t('auth-card.qr.step-2')}</li>
<li>{t('auth-card.qr.step-3')}</li>
</ol>
<div class="auth-card-row">
<button type="button" class="btn-text" onClick={onCancel}>
{t('auth-card.cancel')}
</button>
</div>
</div>
);
};