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 { 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, либо
|
||||||
|
|||||||
@@ -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-логах. */
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user