fix(login): обратный отсчёт кода + возврат на оплату после входа
Жалоба: по ссылке на оплату жмёшь «получить код» — «слишком часто». - на шаге кода кнопка «отправить ещё раз» с отсчётом (60с, синхронно с бэкендом); на 429 ведём отсчёт по Retry-After и не пугаем - кнопка «Получить код» блокируется на время кулдауна - RequireAuth запоминает путь (ссылку на оплату); после входа по коду и через Telegram возвращаем туда, а не на баланс Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { useEffect } from "react";
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useStaff } from "@/store/staff";
|
||||
import { setReturnTo } from "@/lib/returnTo";
|
||||
import { ImpersonationBar } from "@/components/ImpersonationBar";
|
||||
import { LoginPage } from "@/pages/LoginPage";
|
||||
import { TgWaitPage } from "@/pages/TgWaitPage";
|
||||
@@ -19,7 +20,12 @@ import { markNotificationsRead } from "@/api/driver";
|
||||
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
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, либо
|
||||
|
||||
@@ -86,6 +86,19 @@ export async function apiErrorText(err: unknown, fallback: string): Promise<stri
|
||||
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 () =>
|
||||
TgSessionSchema.parse(await api.post("driver/auth/tg/session").json());
|
||||
/** POST, а не GET: в query-параметре pair_session оседал в access-логах. */
|
||||
|
||||
@@ -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 "/";
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
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 { savePairing } from "@/lib/pairing";
|
||||
import { takeReturnTo } from "@/lib/returnTo";
|
||||
import { useAuth } from "@/store/auth";
|
||||
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() {
|
||||
const nav = useNavigate();
|
||||
const setSession = useAuth((s) => s.setSession);
|
||||
@@ -16,6 +27,8 @@ export function LoginPage() {
|
||||
const [channel, setChannel] = useState<string>("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [mode, setMode] = useState<"choose" | "code">("choose");
|
||||
// Секунды до разрешённой повторной отправки кода (0 — можно отправлять).
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
// Prefetched pairing session so the «Войти через Telegram» control is a real
|
||||
// <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.
|
||||
@@ -29,6 +42,13 @@ export function LoginPage() {
|
||||
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() {
|
||||
if (!tg) return;
|
||||
savePairing(tg);
|
||||
@@ -36,7 +56,7 @@ export function LoginPage() {
|
||||
}
|
||||
|
||||
async function requestCode() {
|
||||
if (phone.length < 10) return;
|
||||
if (phone.length < 10 || cooldown > 0) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await authRequest(e164);
|
||||
@@ -51,13 +71,23 @@ export function LoginPage() {
|
||||
}
|
||||
setChannel(r.channel);
|
||||
setStep("code");
|
||||
// Запускаем отсчёт до повторной отправки — чтобы водитель не жал повторно
|
||||
// раньше времени и не упирался в «слишком часто».
|
||||
setCooldown(RESEND_COOLDOWN_SEC);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
const fallback =
|
||||
status === 404 ? "Номер не найден. Обратитесь в парк." :
|
||||
status === 429 ? "Слишком часто. Попробуйте позже." :
|
||||
"Не удалось отправить код";
|
||||
toast.error(await apiErrorText(err, fallback));
|
||||
if (status === 429) {
|
||||
// Точный остаток — из Retry-After; ведём по нему отсчёт и не пугаем.
|
||||
const wait = retryAfterSeconds(err) ?? RESEND_COOLDOWN_SEC;
|
||||
setCooldown(wait);
|
||||
if (step === "phone") setStep("code"); // код мог уйти ранее — дать ввести
|
||||
toast.info(await apiErrorText(err, `Код уже отправляли. Повторить можно через ${wait} сек.`));
|
||||
} else {
|
||||
const fallback =
|
||||
status === 404 ? "Номер не найден. Обратитесь в парк." :
|
||||
"Не удалось отправить код";
|
||||
toast.error(await apiErrorText(err, fallback));
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -69,7 +99,8 @@ export function LoginPage() {
|
||||
try {
|
||||
const r = await authVerify(e164, code);
|
||||
setSession(r.token, r.driver_id);
|
||||
nav("/");
|
||||
// Возврат туда, откуда пришёл (напр. ссылка на оплату), иначе на баланс.
|
||||
nav(takeReturnTo(), { replace: true });
|
||||
} catch (err: unknown) {
|
||||
toast.error(await apiErrorText(err, "Неверный или просроченный код"));
|
||||
} finally {
|
||||
@@ -118,8 +149,8 @@ export function LoginPage() {
|
||||
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"
|
||||
/>
|
||||
<button className="btn-primary" disabled={busy || phone.length < 10} onClick={requestCode}>
|
||||
{busy ? <Spinner /> : "Получить код"}
|
||||
<button className="btn-primary" disabled={busy || phone.length < 10 || cooldown > 0} onClick={requestCode}>
|
||||
{busy ? <Spinner /> : cooldown > 0 ? `Повторить через ${fmtMMSS(cooldown)}` : "Получить код"}
|
||||
</button>
|
||||
<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}>
|
||||
{busy ? <Spinner /> : "Войти"}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { usePairPoll } from "@/hooks/usePairPoll";
|
||||
import { clearPairing, loadPairing } from "@/lib/pairing";
|
||||
import { takeReturnTo } from "@/lib/returnTo";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
|
||||
export function TgWaitPage() {
|
||||
@@ -16,7 +17,8 @@ export function TgWaitPage() {
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
clearPairing();
|
||||
nav("/", { replace: true });
|
||||
// Возврат туда, откуда пришёл (напр. ссылка на оплату), иначе на баланс.
|
||||
nav(takeReturnTo(), { replace: true });
|
||||
}
|
||||
if (status === "expired") {
|
||||
clearPairing();
|
||||
|
||||
Reference in New Issue
Block a user