859 lines
32 KiB
TypeScript
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>
|
|
);
|
|
};
|