fix(login): обратный отсчёт кода + возврат на оплату после входа

Жалоба: по ссылке на оплату жмёшь «получить код» — «слишком часто».
- на шаге кода кнопка «отправить ещё раз» с отсчётом (60с, синхронно с
  бэкендом); на 429 ведём отсчёт по Retry-After и не пугаем
- кнопка «Получить код» блокируется на время кулдауна
- RequireAuth запоминает путь (ссылку на оплату); после входа по коду и
  через Telegram возвращаем туда, а не на баланс

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 15:22:05 +10:00
co-authored by Claude Fable 5
parent a7d5f27b53
commit a2557897a0
5 changed files with 107 additions and 13 deletions
+7 -1
View File
@@ -3,6 +3,7 @@ import { useEffect } from "react";
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
import { useStaff } from "@/store/staff"; import { useStaff } from "@/store/staff";
import { setReturnTo } from "@/lib/returnTo";
import { ImpersonationBar } from "@/components/ImpersonationBar"; import { ImpersonationBar } from "@/components/ImpersonationBar";
import { LoginPage } from "@/pages/LoginPage"; import { LoginPage } from "@/pages/LoginPage";
import { TgWaitPage } from "@/pages/TgWaitPage"; import { TgWaitPage } from "@/pages/TgWaitPage";
@@ -19,7 +20,12 @@ import { markNotificationsRead } from "@/api/driver";
function RequireAuth({ children }: { children: ReactNode }) { function RequireAuth({ children }: { children: ReactNode }) {
const token = useAuth((s) => s.token); const token = useAuth((s) => s.token);
return token ? <>{children}</> : <Navigate to="/login" replace />; const loc = useLocation();
if (token) return <>{children}</>;
// Запоминаем, куда шёл водитель (напр. ссылку на оплату), чтобы вернуть его
// туда после входа, а не на баланс.
setReturnTo(loc.pathname + loc.search);
return <Navigate to="/login" replace />;
} }
/** Отладка существует только для сотрудника: либо он вошёл учёткой CRM, либо /** Отладка существует только для сотрудника: либо он вошёл учёткой CRM, либо
+13
View File
@@ -86,6 +86,19 @@ export async function apiErrorText(err: unknown, fallback: string): Promise<stri
return fallback; return fallback;
} }
/**
* Сколько секунд ждать перед повторным запросом кода. Берём из заголовка
* Retry-After ответа 429; если его нет — null (вызывающий решит сам).
*/
export function retryAfterSeconds(err: unknown): number | null {
if (err instanceof HTTPError) {
const raw = err.response.headers.get("Retry-After");
const n = raw ? parseInt(raw, 10) : NaN;
if (Number.isFinite(n) && n > 0) return n;
}
return null;
}
export const tgSession = async () => export const tgSession = async () =>
TgSessionSchema.parse(await api.post("driver/auth/tg/session").json()); TgSessionSchema.parse(await api.post("driver/auth/tg/session").json());
/** POST, а не GET: в query-параметре pair_session оседал в access-логах. */ /** POST, а не GET: в query-параметре pair_session оседал в access-логах. */
+32
View File
@@ -0,0 +1,32 @@
/**
* Куда вернуть водителя после входа.
*
* Ссылки на оплату (`/pay/:id`) открываются под RequireAuth: незалогиненного
* бросает на /login. Раньше после входа он попадал на баланс, а не на оплату,
* ради которой пришёл. Запоминаем нужный путь тут (переживает переход через
* /tg-wait и онбординг), после любого успешного входа — забираем и очищаем.
*/
const KEY = "pp-return-to";
/** Путь для сохранения — только внутренние маршруты приложения. */
export function setReturnTo(path: string | null | undefined): void {
try {
if (path && path.startsWith("/") && !path.startsWith("//") && path !== "/login") {
sessionStorage.setItem(KEY, path);
}
} catch {
/* приватный режим/недоступный storage — не критично */
}
}
/** Забрать сохранённый путь (и очистить). По умолчанию — на главную. */
export function takeReturnTo(): string {
try {
const v = sessionStorage.getItem(KEY);
sessionStorage.removeItem(KEY);
if (v && v.startsWith("/") && !v.startsWith("//")) return v;
} catch {
/* см. выше */
}
return "/";
}
+52 -11
View File
@@ -1,12 +1,23 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { apiErrorText, authRequest, authVerify, tgSession } from "@/api/driver"; import { apiErrorText, authRequest, authVerify, retryAfterSeconds, tgSession } from "@/api/driver";
import { digitsOnly, formatPhone } from "@/lib/format"; import { digitsOnly, formatPhone } from "@/lib/format";
import { savePairing } from "@/lib/pairing"; import { savePairing } from "@/lib/pairing";
import { takeReturnTo } from "@/lib/returnTo";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
// Кулдаун повторной отправки кода — синхронно с бэкендом (60с). Точное значение
// приходит в Retry-After на 429; это дефолт после успешной отправки.
const RESEND_COOLDOWN_SEC = 60;
function fmtMMSS(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function LoginPage() { export function LoginPage() {
const nav = useNavigate(); const nav = useNavigate();
const setSession = useAuth((s) => s.setSession); const setSession = useAuth((s) => s.setSession);
@@ -16,6 +27,8 @@ export function LoginPage() {
const [channel, setChannel] = useState<string>(""); const [channel, setChannel] = useState<string>("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [mode, setMode] = useState<"choose" | "code">("choose"); const [mode, setMode] = useState<"choose" | "code">("choose");
// Секунды до разрешённой повторной отправки кода (0 — можно отправлять).
const [cooldown, setCooldown] = useState(0);
// Prefetched pairing session so the «Войти через Telegram» control is a real // Prefetched pairing session so the «Войти через Telegram» control is a real
// <a href> the OS opens in Telegram (a synchronous user gesture) WITHOUT // <a href> the OS opens in Telegram (a synchronous user gesture) WITHOUT
// navigating the PWA away — the app stays mounted on /tg-wait and polls. // navigating the PWA away — the app stays mounted on /tg-wait and polls.
@@ -29,6 +42,13 @@ export function LoginPage() {
return () => { alive = false; }; return () => { alive = false; };
}, []); }, []);
// Тик обратного отсчёта повторной отправки.
useEffect(() => {
if (cooldown <= 0) return;
const t = setTimeout(() => setCooldown((s) => Math.max(0, s - 1)), 1000);
return () => clearTimeout(t);
}, [cooldown]);
function startTg() { function startTg() {
if (!tg) return; if (!tg) return;
savePairing(tg); savePairing(tg);
@@ -36,7 +56,7 @@ export function LoginPage() {
} }
async function requestCode() { async function requestCode() {
if (phone.length < 10) return; if (phone.length < 10 || cooldown > 0) return;
setBusy(true); setBusy(true);
try { try {
const r = await authRequest(e164); const r = await authRequest(e164);
@@ -51,13 +71,23 @@ export function LoginPage() {
} }
setChannel(r.channel); setChannel(r.channel);
setStep("code"); setStep("code");
// Запускаем отсчёт до повторной отправки — чтобы водитель не жал повторно
// раньше времени и не упирался в «слишком часто».
setCooldown(RESEND_COOLDOWN_SEC);
} catch (err: unknown) { } catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status; const status = (err as { response?: { status?: number } })?.response?.status;
const fallback = if (status === 429) {
status === 404 ? "Номер не найден. Обратитесь в парк." : // Точный остаток — из Retry-After; ведём по нему отсчёт и не пугаем.
status === 429 ? "Слишком часто. Попробуйте позже." : const wait = retryAfterSeconds(err) ?? RESEND_COOLDOWN_SEC;
"Не удалось отправить код"; setCooldown(wait);
toast.error(await apiErrorText(err, fallback)); if (step === "phone") setStep("code"); // код мог уйти ранее — дать ввести
toast.info(await apiErrorText(err, `Код уже отправляли. Повторить можно через ${wait} сек.`));
} else {
const fallback =
status === 404 ? "Номер не найден. Обратитесь в парк." :
"Не удалось отправить код";
toast.error(await apiErrorText(err, fallback));
}
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -69,7 +99,8 @@ export function LoginPage() {
try { try {
const r = await authVerify(e164, code); const r = await authVerify(e164, code);
setSession(r.token, r.driver_id); setSession(r.token, r.driver_id);
nav("/"); // Возврат туда, откуда пришёл (напр. ссылка на оплату), иначе на баланс.
nav(takeReturnTo(), { replace: true });
} catch (err: unknown) { } catch (err: unknown) {
toast.error(await apiErrorText(err, "Неверный или просроченный код")); toast.error(await apiErrorText(err, "Неверный или просроченный код"));
} finally { } finally {
@@ -118,8 +149,8 @@ export function LoginPage() {
onChange={(e) => setPhone(digitsOnly(e.target.value).replace(/^7/, "").slice(0, 10))} onChange={(e) => setPhone(digitsOnly(e.target.value).replace(/^7/, "").slice(0, 10))}
className="card w-full px-4 py-3 mb-3 text-base bg-surface" className="card w-full px-4 py-3 mb-3 text-base bg-surface"
/> />
<button className="btn-primary" disabled={busy || phone.length < 10} onClick={requestCode}> <button className="btn-primary" disabled={busy || phone.length < 10 || cooldown > 0} onClick={requestCode}>
{busy ? <Spinner /> : "Получить код"} {busy ? <Spinner /> : cooldown > 0 ? `Повторить через ${fmtMMSS(cooldown)}` : "Получить код"}
</button> </button>
<p className="text-muted text-xs mt-3">Код придёт в Telegram или MAX.</p> <p className="text-muted text-xs mt-3">Код придёт в Telegram или MAX.</p>
</> </>
@@ -136,7 +167,17 @@ export function LoginPage() {
<button className="btn-primary" disabled={busy || code.length < 4} onClick={verify}> <button className="btn-primary" disabled={busy || code.length < 4} onClick={verify}>
{busy ? <Spinner /> : "Войти"} {busy ? <Spinner /> : "Войти"}
</button> </button>
<button className="btn-ghost mt-2" onClick={() => setStep("phone")}>Изменить номер</button> {/* Повторная отправка прямо здесь: не пришёл код — не надо
возвращаться к номеру и упираться в «слишком часто». Кнопка
ждёт кулдаун и показывает, сколько осталось. */}
<button
className="btn-ghost mt-2"
disabled={busy || cooldown > 0}
onClick={requestCode}
>
{cooldown > 0 ? `Отправить ещё раз через ${fmtMMSS(cooldown)}` : "Отправить код ещё раз"}
</button>
<button className="btn-ghost mt-1" onClick={() => setStep("phone")}>Изменить номер</button>
</> </>
)} )}
</> </>
+3 -1
View File
@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { usePairPoll } from "@/hooks/usePairPoll"; import { usePairPoll } from "@/hooks/usePairPoll";
import { clearPairing, loadPairing } from "@/lib/pairing"; import { clearPairing, loadPairing } from "@/lib/pairing";
import { takeReturnTo } from "@/lib/returnTo";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
export function TgWaitPage() { export function TgWaitPage() {
@@ -16,7 +17,8 @@ export function TgWaitPage() {
useEffect(() => { useEffect(() => {
if (status === "authenticated") { if (status === "authenticated") {
clearPairing(); clearPairing();
nav("/", { replace: true }); // Возврат туда, откуда пришёл (напр. ссылка на оплату), иначе на баланс.
nav(takeReturnTo(), { replace: true });
} }
if (status === "expired") { if (status === "expired") {
clearPairing(); clearPairing();