feat(pwa): вход для сотрудника, просмотр от лица водителя и вкладка «Отладка»

Приложение обкатывается на боевом контуре, поэтому интерфейс надо проверять на
живых данных — а живые данные лежат у настоящих водителей.

* «Вход для сотрудника» (/dev) — логин учётки CRM, поддержан второй шаг 2FA;
  дальше поиск водителя по ФИО/телефону/id и просмотр его экранов.
* Плашка «Смотрю как …» над всеми экранами: интерфейс в этом режиме неотличим
  от водительского, и чужой баланс легко принять за свой.
* Вкладка «Отладка» (/debug, водителю недоступна): смена водителя, driver_id,
  режим, срок токена, адрес API, коммит и время сборки, журнал последних
  40 ответивших запросов, сброс кэша сервис-воркера.
* Пополнение под просмотром идёт в учебный контур: экран /mock-pay/:orderId
  выбирает, что «ответит» банк, а настоящий экран оплаты разбирает эти статусы
  своим боевым кодом. Ни рубля, ни чека 54-ФЗ.

Заодно доезжает незакоммиченная работа прошлой сессии: дев-прокси в
vite.config.ts, его тест и разобранный .env.example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 13:15:02 +10:00
co-authored by Claude Opus 5
parent aff1a45006
commit ceec3db364
27 changed files with 1308 additions and 17 deletions
+20
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { useAuth } from "@/store/auth";
import { useStaff } from "@/store/staff";
import { ImpersonationBar } from "@/components/ImpersonationBar";
import { LoginPage } from "@/pages/LoginPage";
import { TgWaitPage } from "@/pages/TgWaitPage";
import { OnboardingPage } from "@/pages/OnboardingPage";
@@ -8,23 +10,41 @@ import { BalancePage } from "@/pages/BalancePage";
import { TopupPage } from "@/pages/TopupPage";
import { PaymentPage } from "@/pages/PaymentPage";
import { HistoryPage } from "@/pages/HistoryPage";
import { DevLoginPage } from "@/pages/DevLoginPage";
import { DebugPage } from "@/pages/DebugPage";
import { MockPayPage } from "@/pages/MockPayPage";
function RequireAuth({ children }: { children: ReactNode }) {
const token = useAuth((s) => s.token);
return token ? <>{children}</> : <Navigate to="/login" replace />;
}
/** Отладка существует только для сотрудника: либо он вошёл учёткой CRM, либо
* прямо сейчас смотрит от лица водителя. Обычному водителю адрес /debug ничего
* не даёт — его уводит на главную. */
function RequireStaff({ children }: { children: ReactNode }) {
const impersonated = useAuth((s) => s.impersonated);
const staffToken = useStaff((s) => s.token);
return impersonated || staffToken ? <>{children}</> : <Navigate to="/" replace />;
}
export default function App() {
return (
<div className="mx-auto max-w-md min-h-full px-4 pt-3 pb-8">
{/* Плашка живёт над маршрутами: чей экран открыт, видно на каждом из них,
а не только там, где про неё вспомнили. Сама решает, показываться ли. */}
<ImpersonationBar />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/dev" element={<DevLoginPage />} />
<Route path="/tg-wait" element={<TgWaitPage />} />
<Route path="/onboarding" element={<OnboardingPage />} />
<Route path="/" element={<RequireAuth><BalancePage /></RequireAuth>} />
<Route path="/topup" element={<RequireAuth><TopupPage /></RequireAuth>} />
<Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} />
<Route path="/mock-pay/:orderId" element={<RequireAuth><MockPayPage /></RequireAuth>} />
<Route path="/history" element={<RequireAuth><HistoryPage /></RequireAuth>} />
<Route path="/debug" element={<RequireAuth><RequireStaff><DebugPage /></RequireStaff></RequireAuth>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
+19 -1
View File
@@ -1,7 +1,11 @@
import ky, { type KyInstance } from "ky";
import { pushEntry, shortPath } from "@/lib/reqlog";
import { useAuth } from "@/store/auth";
const API_BASE = import.meta.env.VITE_API_BASE ?? "https://crm.pptaxi.ru/api";
export const API_BASE = import.meta.env.VITE_API_BASE ?? "https://crm.pptaxi.ru/api";
/** Засечка времени на конкретный запрос. WeakMap — чтобы не держать Request'ы. */
const started = new WeakMap<Request, number>();
export const api: KyInstance = ky.create({
prefixUrl: API_BASE,
@@ -10,10 +14,24 @@ export const api: KyInstance = ky.create({
hooks: {
beforeRequest: [
(request) => {
started.set(request, performance.now());
const token = useAuth.getState().token;
if (token) request.headers.set("Authorization", `Bearer ${token}`);
},
],
// В журнал попадают ОТВЕТИВШИЕ запросы, включая 4xx/5xx. Запрос, оборвавшийся
// без ответа (таймаут, пропавшая сеть), ky сюда не приносит — на экране
// отладки об этом сказано прямо, чтобы пустая строка не читалась как «дошло».
afterResponse: [
(request, _options, response) => {
pushEntry({
method: request.method,
path: shortPath(request.url),
status: response.status,
ms: Math.round(performance.now() - (started.get(request) ?? performance.now())),
});
},
],
beforeError: [
(error) => {
if (error.response?.status === 401) useAuth.getState().clear();
+10
View File
@@ -35,6 +35,9 @@ export const BucketsSchema = z.object({
});
export const TopupSchema = z.object({
order_id: z.string(), pay_url: z.string(), commission: z.number(), total: z.number(),
// Учебный платёж: сотрудник смотрит от лица водителя, банка в этой ветке нет
// (см. backend modules/payments/mock_pay.py). У обычного водителя всегда false.
mock: z.boolean().default(false),
});
export const PaymentStateSchema = z.object({ order_id: z.string(), status: z.string() });
export const CommissionSchema = z.object({ rate: z.number() });
@@ -99,3 +102,10 @@ export const getPayments = async () =>
PaymentsSchema.parse(await api.get("driver/payments").json());
export const getCommission = async () =>
CommissionSchema.parse(await api.get("driver/commission").json());
/** Исход учебного платежа. Работает только под просмотром сотрудника. */
export const mockPayOutcome = async (
orderId: string, outcome: "paid" | "rejected" | "expired" | "reversed",
) => PaymentStateSchema.parse(
await api.post(`driver/mock-pay/${orderId}`, { json: { outcome } }).json(),
);
+85
View File
@@ -0,0 +1,85 @@
import ky from "ky";
import { z } from "zod";
import { pushEntry, shortPath } from "@/lib/reqlog";
import { API_BASE } from "./client";
import { useStaff } from "@/store/staff";
/**
* Учётка CRM внутри приложения водителя — только ради просмотра от чужого лица.
*
* Свой экземпляр ky, а не общий `api`: тот подставляет ВОДИТЕЛЬСКИЙ токен, и на
* запросе к /driver/staff/* заголовок оказался бы чужим. Плюс 401 здесь означает
* «протух сеанс сотрудника», а не «водителя выкинуло».
*/
const started = new WeakMap<Request, number>();
const staffApi = ky.create({
prefixUrl: API_BASE,
timeout: 30_000,
hooks: {
beforeRequest: [
(request) => {
started.set(request, performance.now());
const token = useStaff.getState().token;
if (token) request.headers.set("Authorization", `Bearer ${token}`);
},
],
afterResponse: [
(request, _o, response) => {
pushEntry({
method: request.method, path: shortPath(request.url), status: response.status,
ms: Math.round(performance.now() - (started.get(request) ?? performance.now())),
});
},
],
beforeError: [
(error) => {
if (error.response?.status === 401) useStaff.getState().clear();
return error;
},
],
},
});
export const LoginSchema = z.object({
access_token: z.string().nullable().default(null),
requires_2fa: z.boolean().default(false),
partial_token: z.string().nullable().default(null),
});
export const DriverBriefSchema = z.object({
id: z.number(),
fio: z.string(),
phone: z.string(),
status: z.string(),
driver_kind: z.string(),
});
export type DriverBrief = z.infer<typeof DriverBriefSchema>;
export const DriversSchema = z.object({ drivers: z.array(DriverBriefSchema) });
export const ImpersonateSchema = z.object({
token: z.string(),
driver: DriverBriefSchema,
});
/** Логин CRM. Форма, а не JSON: эндпоинт принимает OAuth2PasswordRequestForm. */
export const staffLogin = async (username: string, password: string) => {
const body = new URLSearchParams({ username, password });
return LoginSchema.parse(await staffApi.post("auth/login", { body }).json());
};
/** Второй шаг, если у учётки включена двухфакторка. */
export const staffLogin2fa = async (partialToken: string, code: string) =>
LoginSchema.parse(await staffApi.post("auth/2fa", {
json: { partial_token: partialToken, code, remember_device: false },
}).json());
export const searchDrivers = async (q: string) =>
DriversSchema.parse(await staffApi.get("driver/staff/drivers", {
searchParams: { q, limit: 30 },
}).json());
export const impersonate = async (driverId: number) =>
ImpersonateSchema.parse(await staffApi.post("driver/staff/impersonate", {
json: { driver_id: driverId },
}).json());
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { Search } from "lucide-react";
import { toast } from "sonner";
import { apiErrorText } from "@/api/driver";
import { impersonate, searchDrivers, type DriverBrief } from "@/api/staff";
import { useAuth } from "@/store/auth";
import { Spinner } from "@/components/Spinner";
/**
* Выбор водителя, от лица которого смотреть. Один компонент на два места:
* первый вход сотрудника (/dev) и смена водителя на вкладке «Отладка».
*/
export function DriverPicker({ onPicked }: { onPicked: (d: DriverBrief) => void }) {
const setSession = useAuth((s) => s.setSession);
const [q, setQ] = useState("");
const [list, setList] = useState<DriverBrief[]>([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<number | null>(null);
useEffect(() => {
let alive = true;
setLoading(true);
// Пауза перед запросом: иначе поиск уходит на каждую букву, и ответы
// возвращаются вперемешку — список прыгает.
const t = setTimeout(() => {
searchDrivers(q)
.then((r) => { if (alive) setList(r.drivers); })
.catch(async (e) => { if (alive) toast.error(await apiErrorText(e, "Не удалось найти водителей")); })
.finally(() => { if (alive) setLoading(false); });
}, 300);
return () => { alive = false; clearTimeout(t); };
}, [q]);
async function pick(d: DriverBrief) {
setBusyId(d.id);
try {
const r = await impersonate(d.id);
setSession(r.token, r.driver.id, { impersonated: true, driverName: r.driver.fio });
onPicked(r.driver);
} catch (e: unknown) {
toast.error(await apiErrorText(e, "Не удалось открыть приложение за водителя"));
} finally {
setBusyId(null);
}
}
return (
<div>
<div className="card flex items-center gap-2 px-3 py-2.5 mb-3 bg-white">
<Search size={16} className="text-muted shrink-0" />
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="ФИО, телефон или id"
className="w-full text-base bg-transparent outline-none"
/>
</div>
{loading ? (
<div className="flex justify-center py-8"><Spinner /></div>
) : list.length === 0 ? (
<p className="text-muted text-sm text-center py-8">Никого не нашли</p>
) : (
<div className="card divide-y divide-line">
{list.map((d) => (
<button
key={d.id}
disabled={busyId !== null}
onClick={() => pick(d)}
className="w-full text-left px-4 py-3 flex items-center justify-between gap-3 disabled:opacity-50"
>
<span className="min-w-0">
<span className="block text-sm font-semibold truncate">{d.fio}</span>
<span className="block text-muted text-xs truncate">
#{d.id}{d.phone ? ` · ${d.phone}` : ""}{d.status ? ` · ${d.status}` : ""}
</span>
</span>
{busyId === d.id ? <Spinner /> : <span className="text-muted text-xs shrink-0">смотреть</span>}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,34 @@
import { useLocation, useNavigate } from "react-router-dom";
import { Eye } from "lucide-react";
import { useAuth } from "@/store/auth";
/**
* Плашка «вы смотрите чужой экран». Видна на каждом экране и всегда, пока
* действует токен просмотра.
*
* Она не украшение: интерфейс в этом режиме неотличим от водительского — те же
* суммы, те же долги, та же история. Без постоянного напоминания легко принять
* чужой баланс за свой и, например, позвонить водителю с чужими цифрами.
*/
export function ImpersonationBar() {
const nav = useNavigate();
const loc = useLocation();
const { impersonated, driverName, driverId } = useAuth();
if (!impersonated) return null;
const onDebug = loc.pathname === "/debug";
return (
<div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-xl2 bg-ink text-cream text-xs">
<Eye size={14} className="shrink-0" />
<span className="min-w-0 truncate">
Смотрю как <b>{driverName ?? `водитель #${driverId}`}</b>
</span>
<button
className="ml-auto shrink-0 underline underline-offset-2"
onClick={() => nav(onDebug ? "/" : "/debug")}
>
{onDebug ? "к приложению" : "отладка"}
</button>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Чтение полезной нагрузки JWT без проверки подписи — для экрана отладки.
*
* Проверять подпись здесь нечем и не нужно: решение о доступе принимает бэкенд,
* а приложению эти поля нужны ровно для того, чтобы показать человеку, чем он
* сейчас держится — до какого времени живёт токен и чей это сеанс.
*/
export interface TokenInfo {
sub: string | null;
exp: number | null;
impersonated: boolean;
}
export function readToken(token: string | null): TokenInfo | null {
if (!token) return null;
const part = token.split(".")[1];
if (!part) return null;
try {
const json = atob(part.replace(/-/g, "+").replace(/_/g, "/"));
const claims = JSON.parse(json) as Record<string, unknown>;
return {
sub: typeof claims.sub === "string" ? claims.sub : null,
exp: typeof claims.exp === "number" ? claims.exp : null,
impersonated: claims.imp === true,
};
} catch {
return null;
}
}
/** «через 7 ч 20 мин» / «истёк» — человеку, а не в секундах. */
export function expiresIn(exp: number | null): string {
if (exp == null) return "—";
const left = exp * 1000 - Date.now();
if (left <= 0) return "истёк";
const h = Math.floor(left / 3_600_000);
const m = Math.floor((left % 3_600_000) / 60_000);
if (h >= 24) return `через ${Math.floor(h / 24)} дн ${h % 24} ч`;
return h > 0 ? `через ${h} ч ${m} мин` : `через ${m} мин`;
}
@@ -0,0 +1,28 @@
import { describe, it, expect, beforeEach } from "vitest";
import { clearLog, getLog, pushEntry, shortPath, subscribe } from "./reqlog";
describe("журнал запросов", () => {
beforeEach(() => clearLog());
it("новые записи сверху, старые вытесняются", () => {
for (let i = 0; i < 45; i++) pushEntry({ method: "GET", path: `/api/${i}`, status: 200, ms: 1 });
const log = getLog();
expect(log.length).toBe(40);
expect(log[0].path).toBe("/api/44");
});
it("подписчик узнаёт о записи — на этом держится экран отладки", () => {
let calls = 0;
const off = subscribe(() => { calls += 1; });
pushEntry({ method: "GET", path: "/api/x", status: 200, ms: 1 });
expect(calls).toBe(1);
off();
pushEntry({ method: "GET", path: "/api/y", status: 200, ms: 1 });
expect(calls).toBe(1);
});
it("query отбрасывается: в нём ездят идентификаторы", () => {
expect(shortPath("https://crm.pptaxi.ru/api/driver/payment/x?token=secret"))
.toBe("/api/driver/payment/x");
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* Кольцевой журнал запросов приложения — для вкладки «Отладка».
*
* Зачем он, если есть DevTools: приложение живёт на телефоне, и половина
* интересных случаев (протухший токен, отвалившийся Wi-Fi, 429 от лимита) видна
* только там, где DevTools нет. Журнал даёт те же четыре факта — метод, путь,
* статус, длительность — прямо на экране.
*
* Живёт в памяти и умирает вместе с вкладкой: это отладочный след, а не история.
* В нём НЕТ ни тел запросов, ни заголовков — токен водителя в отладочный экран
* попасть не должен.
*/
export interface ReqEntry {
id: number;
method: string;
path: string;
status: number | null;
ms: number;
at: number;
}
const LIMIT = 40;
let seq = 0;
let entries: ReqEntry[] = [];
const listeners = new Set<() => void>();
export function pushEntry(e: Omit<ReqEntry, "id" | "at">): void {
seq += 1;
entries = [{ ...e, id: seq, at: Date.now() }, ...entries].slice(0, LIMIT);
listeners.forEach((l) => l());
}
export function clearLog(): void {
entries = [];
listeners.forEach((l) => l());
}
/** Снимок для useSyncExternalStore: ссылка меняется только при новой записи. */
export function getLog(): ReqEntry[] {
return entries;
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Путь без origin и без query: в query ездят идентификаторы, а экран публичный. */
export function shortPath(url: string): string {
try {
return new URL(url).pathname;
} catch {
return url.split("?")[0];
}
}
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("@/api/staff", () => ({
searchDrivers: vi.fn(async () => ({ drivers: [] })),
impersonate: vi.fn(),
}));
// Экран баланса, на который уводит редирект, тянет свои запросы — отдаём пустышки.
vi.mock("@/api/driver", () => ({
apiErrorText: vi.fn(async (_e: unknown, f: string) => f),
getBalance: vi.fn(async () => ({ accounts: [], pending_topup: 0 })),
}));
import App from "@/App";
import { DebugPage } from "./DebugPage";
import { clearLog, pushEntry } from "@/lib/reqlog";
import { useAuth } from "@/store/auth";
import { useStaff } from "@/store/staff";
function renderAt(path: string) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[path]}><App /></MemoryRouter>
</QueryClientProvider>
);
}
describe("вкладка отладки", () => {
beforeEach(() => { useAuth.getState().clear(); useStaff.getState().clear(); clearLog(); });
it("водителя на /debug не пускает — уводит в приложение", () => {
useAuth.getState().setSession("t", 42);
renderAt("/debug");
expect(screen.queryByRole("heading", { name: "Отладка" })).not.toBeInTheDocument();
});
it("сотруднику показывает, от чьего лица открыт экран", () => {
useAuth.getState().setSession("t", 42, { impersonated: true, driverName: "Петров Пётр" });
renderAt("/debug");
expect(screen.getByRole("heading", { name: "Отладка" })).toBeInTheDocument();
// Плашка живёт над маршрутами и видна на любом экране, включая этот.
expect(screen.getByText(/Смотрю как/)).toBeInTheDocument();
expect(screen.getAllByText(/Петров Пётр/).length).toBeGreaterThan(0);
});
it("плашки нет у обычного водителя", () => {
useAuth.getState().setSession("t", 42);
renderAt("/");
expect(screen.queryByText(/Смотрю как/)).not.toBeInTheDocument();
});
it("журнал запросов виден со статусами и временем", () => {
useAuth.getState().setSession("t", 42, { impersonated: true, driverName: "Петров" });
pushEntry({ method: "GET", path: "/api/driver/balance", status: 401, ms: 123 });
render(
<QueryClientProvider client={new QueryClient()}>
<MemoryRouter><DebugPage /></MemoryRouter>
</QueryClientProvider>
);
expect(screen.getByText("/api/driver/balance")).toBeInTheDocument();
expect(screen.getByText("401")).toBeInTheDocument();
expect(screen.getByText("123 мс")).toBeInTheDocument();
});
});
+127
View File
@@ -0,0 +1,127 @@
import { useState, useSyncExternalStore } from "react";
import { useNavigate } from "react-router-dom";
import { LogOut, RefreshCw, Trash2, UserCog } from "lucide-react";
import { toast } from "sonner";
import { API_BASE } from "@/api/client";
import { clearLog, getLog, subscribe } from "@/lib/reqlog";
import { expiresIn, readToken } from "@/lib/jwt";
import { useAuth } from "@/store/auth";
import { useStaff } from "@/store/staff";
import { DriverPicker } from "@/components/DriverPicker";
function Row({ k, v }: { k: string; v: string }) {
return (
<div className="flex justify-between gap-3 py-2 border-b border-line last:border-0">
<span className="text-muted text-xs shrink-0">{k}</span>
<span className="text-xs text-right break-all">{v}</span>
</div>
);
}
/**
* Вкладка «Отладка» — то, чего в приложении водителя нет и быть не должно:
* от чьего лица открыт экран, куда ходят запросы, чем они отвечают.
*
* Показывается только сотруднику (есть учётка CRM или действует просмотр). У
* водителя маршрут ведёт на главную — см. App.
*/
export function DebugPage() {
const nav = useNavigate();
const { token, driverId, driverName, impersonated, clear } = useAuth();
const staff = useStaff();
const log = useSyncExternalStore(subscribe, getLog);
const [switching, setSwitching] = useState(false);
const info = readToken(token);
async function hardReload() {
// Кэш сервис-воркера переживает деплой и показывает вчерашний бандл — на
// отладке это стоит часа «почему правка не приехала».
try {
const regs = await navigator.serviceWorker?.getRegistrations?.();
await Promise.all((regs ?? []).map((r) => r.unregister()));
const keys = await caches?.keys?.();
await Promise.all((keys ?? []).map((k) => caches.delete(k)));
toast.success("Кэш сброшен, перезагружаю");
} catch {
toast.error("Не удалось сбросить кэш");
}
location.reload();
}
return (
<div className="pt-3">
<h1 className="text-lg font-extrabold mb-4">Отладка</h1>
<div className="card p-4 mb-4">
<p className="text-muted text-xs mb-1">Сейчас смотрю</p>
<p className="text-sm font-bold mb-3">
{impersonated ? (driverName ?? `Водитель #${driverId}`) : "Свой аккаунт водителя"}
</p>
<button className="btn-ghost flex items-center justify-center gap-2"
onClick={() => setSwitching((s) => !s)}>
<UserCog size={16} /> {switching ? "Свернуть" : "Сменить водителя"}
</button>
{switching && (
<div className="mt-4">
<DriverPicker onPicked={(d) => { setSwitching(false); toast.success(`Смотрю как ${d.fio}`); }} />
</div>
)}
</div>
<div className="card p-4 mb-4">
<p className="text-muted text-xs mb-2">Сведения</p>
<Row k="driver_id" v={String(driverId ?? "—")} />
<Row k="режим" v={impersonated ? "просмотр сотрудником (imp)" : "обычный вход водителя"} />
<Row k="сотрудник" v={staff.username ?? "не входил"} />
<Row k="токен истекает" v={expiresIn(info?.exp ?? null)} />
<Row k="API" v={API_BASE} />
<Row k="сборка" v={`${__BUILD_COMMIT__} · ${__BUILD_TIME__}`} />
<Row k="сеть" v={navigator.onLine ? "онлайн" : "офлайн"} />
</div>
<div className="card p-4 mb-4">
<div className="flex items-center justify-between mb-2">
<p className="text-muted text-xs">Последние запросы</p>
<button className="text-muted text-xs flex items-center gap-1" onClick={clearLog}>
<Trash2 size={12} /> очистить
</button>
</div>
{log.length === 0 ? (
<p className="text-muted text-xs py-2">Пока пусто</p>
) : (
<div className="font-mono text-[11px] leading-5">
{log.map((e) => (
<div key={e.id} className="flex gap-2 border-b border-line last:border-0 py-1">
<span className={e.status && e.status >= 400 ? "text-neg" : "text-pos"}>
{e.status ?? "—"}
</span>
<span className="text-muted">{e.method}</span>
<span className="truncate">{e.path}</span>
<span className="ml-auto text-muted shrink-0">{e.ms} мс</span>
</div>
))}
</div>
)}
<p className="text-muted text-[11px] mt-2">
Запрос, оборвавшийся без ответа, сюда не попадает.
</p>
</div>
<button className="btn-ghost flex items-center justify-center gap-2 mb-2" onClick={hardReload}>
<RefreshCw size={16} /> Сбросить кэш и перезагрузить
</button>
<button
className="btn-ghost flex items-center justify-center gap-2"
onClick={() => { clear(); nav(staff.token ? "/dev" : "/login"); }}
>
<LogOut size={16} /> {impersonated ? "Выйти из просмотра" : "Выйти"}
</button>
{staff.token && (
<button className="text-muted text-xs w-full mt-4"
onClick={() => { clear(); staff.clear(); nav("/login"); }}>
Выйти и из учётки сотрудника
</button>
)}
</div>
);
}
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
vi.mock("@/api/staff", () => ({
staffLogin: vi.fn(async () => ({ access_token: "staff-tok", requires_2fa: false, partial_token: null })),
staffLogin2fa: vi.fn(async () => ({ access_token: "staff-tok", requires_2fa: false, partial_token: null })),
searchDrivers: vi.fn(async () => ({
drivers: [{ id: 42, fio: "Петров Пётр", phone: "+79141234567", status: "Работает", driver_kind: "park" }],
})),
impersonate: vi.fn(async () => ({
token: "driver-tok",
driver: { id: 42, fio: "Петров Пётр", phone: "+79141234567", status: "Работает", driver_kind: "park" },
})),
}));
vi.mock("@/api/driver", () => ({ apiErrorText: vi.fn(async (_e: unknown, f: string) => f) }));
import { DevLoginPage } from "./DevLoginPage";
import * as staffApi from "@/api/staff";
import { useAuth } from "@/store/auth";
import { useStaff } from "@/store/staff";
const wrap = () => render(<MemoryRouter><DevLoginPage /></MemoryRouter>);
describe("DevLoginPage", () => {
beforeEach(() => {
useAuth.getState().clear();
useStaff.getState().clear();
vi.clearAllMocks();
});
it("логин учёткой CRM, выбор водителя — и сеанс помечен как просмотр", async () => {
wrap();
await userEvent.type(screen.getByPlaceholderText("Логин"), "vlad");
await userEvent.type(screen.getByPlaceholderText("Пароль"), "secret");
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
expect(staffApi.staffLogin).toHaveBeenCalledWith("vlad", "secret");
expect(useStaff.getState().token).toBe("staff-tok");
await userEvent.click(await screen.findByRole("button", { name: /Петров Пётр/ }));
expect(staffApi.impersonate).toHaveBeenCalledWith(42);
const auth = useAuth.getState();
// Метка просмотра — то, на чём держатся и плашка, и запрет боевой оплаты.
expect(auth.token).toBe("driver-tok");
expect(auth.driverId).toBe(42);
expect(auth.impersonated).toBe(true);
expect(auth.driverName).toBe("Петров Пётр");
});
it("двухфакторка: пароль сам по себе сеанса не открывает", async () => {
vi.mocked(staffApi.staffLogin).mockResolvedValueOnce({
access_token: null, requires_2fa: true, partial_token: "partial",
});
wrap();
await userEvent.type(screen.getByPlaceholderText("Логин"), "vlad");
await userEvent.type(screen.getByPlaceholderText("Пароль"), "secret");
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
expect(useStaff.getState().token).toBeNull();
await userEvent.type(await screen.findByPlaceholderText("000000"), "123456");
await userEvent.click(screen.getByRole("button", { name: "Подтвердить" }));
expect(staffApi.staffLogin2fa).toHaveBeenCalledWith("partial", "123456");
expect(useStaff.getState().token).toBe("staff-tok");
});
});
@@ -0,0 +1,122 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { apiErrorText } from "@/api/driver";
import { staffLogin, staffLogin2fa } from "@/api/staff";
import { useStaff } from "@/store/staff";
import { DriverPicker } from "@/components/DriverPicker";
import { Spinner } from "@/components/Spinner";
/**
* Вход сотрудника — тот же логин и пароль, что в CRM.
*
* Экран нужен, пока приложение обкатывается: интерфейс проверяется на живых
* данных, а живые данные лежат у настоящих водителей. Отдельного входа «за
* водителя» здесь нет: сотрудник входит СОБОЙ, а дальше выбирает, чей экран
* смотреть, — и это записывается в аудит CRM.
*/
export function DevLoginPage() {
const nav = useNavigate();
const { token: staffToken, username: staffName, setStaff, clear } = useStaff();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [partial, setPartial] = useState<string | null>(null);
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
async function submit() {
if (!username || !password || busy) return;
setBusy(true);
try {
const r = await staffLogin(username, password);
if (r.requires_2fa && r.partial_token) {
setPartial(r.partial_token);
return;
}
if (!r.access_token) throw new Error("no token");
setStaff(r.access_token, username);
} catch (e: unknown) {
toast.error(await apiErrorText(e, "Неверный логин или пароль"));
} finally {
setBusy(false);
}
}
async function submit2fa() {
if (!partial || code.length < 6 || busy) return;
setBusy(true);
try {
const r = await staffLogin2fa(partial, code);
if (!r.access_token) throw new Error("no token");
setStaff(r.access_token, username);
setPartial(null);
} catch (e: unknown) {
toast.error(await apiErrorText(e, "Код не подошёл"));
} finally {
setBusy(false);
}
}
if (staffToken) {
return (
<div className="pt-10">
<h1 className="text-lg font-extrabold mb-1">Кого смотрим</h1>
<p className="text-muted text-xs mb-5">
Сотрудник: {staffName}. Приложение откроется от лица выбранного водителя,
деньги в этом режиме не двигаются.
</p>
<DriverPicker onPicked={() => nav("/")} />
<button className="btn-ghost mt-5" onClick={clear}>Выйти из учётки сотрудника</button>
</div>
);
}
return (
<div className="pt-16">
<h1 className="text-xl font-extrabold mb-1">Вход для сотрудника</h1>
<p className="text-muted text-sm mb-8">Учётная запись Premium CRM</p>
{partial ? (
<>
<p className="text-sm mb-2">Код из приложения-аутентификатора.</p>
<input
inputMode="numeric"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="000000"
className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-white"
/>
<button className="btn-primary" disabled={busy || code.length < 6} onClick={submit2fa}>
{busy ? <Spinner /> : "Подтвердить"}
</button>
<button className="btn-ghost mt-2" onClick={() => { setPartial(null); setCode(""); }}>
Назад
</button>
</>
) : (
<>
<input
autoCapitalize="none"
autoCorrect="off"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Логин"
className="card w-full px-4 py-3 mb-2 text-base bg-white"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
placeholder="Пароль"
className="card w-full px-4 py-3 mb-3 text-base bg-white"
/>
<button className="btn-primary" disabled={busy || !username || !password} onClick={submit}>
{busy ? <Spinner /> : "Войти"}
</button>
<button className="btn-ghost mt-2" onClick={() => nav("/login")}>Я водитель</button>
</>
)}
</div>
);
}
@@ -101,6 +101,11 @@ export function LoginPage() {
)}
<p className="text-muted text-xs mt-3">Telegram подтвердит ваш номер автоматически.</p>
<button className="btn-ghost mt-4" onClick={() => setMode("code")}>Войти по коду</button>
{/* Пока идёт обкатка, сотруднику надо попадать внутрь без карточки
водителя и без бота — учёткой CRM. Ссылка неприметная намеренно. */}
<button className="text-muted text-xs w-full mt-6" onClick={() => nav("/dev")}>
Вход для сотрудника
</button>
</>
) : (
<>
@@ -0,0 +1,42 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
vi.mock("@/api/driver", () => ({
mockPayOutcome: vi.fn(async () => ({ order_id: "mock-1", status: "PAID" })),
apiErrorText: vi.fn(async (_e: unknown, f: string) => f),
}));
import { MockPayPage } from "./MockPayPage";
import * as driver from "@/api/driver";
import { useAuth } from "@/store/auth";
const wrap = () =>
render(
<MemoryRouter initialEntries={["/mock-pay/mock-1"]}>
<Routes>
<Route path="/mock-pay/:orderId" element={<MockPayPage />} />
<Route path="/pay/:orderId" element={<p>экран оплаты</p>} />
</Routes>
</MemoryRouter>
);
describe("MockPayPage", () => {
beforeEach(() => { useAuth.getState().clear(); vi.clearAllMocks(); });
it("исход уходит на бэкенд и возвращает на настоящий экран оплаты", async () => {
useAuth.getState().setSession("t", 42, { impersonated: true, driverName: "Петров" });
wrap();
await userEvent.click(screen.getByRole("button", { name: /Банк отклонил/ }));
expect(driver.mockPayOutcome).toHaveBeenCalledWith("mock-1", "rejected");
expect(await screen.findByText("экран оплаты")).toBeInTheDocument();
});
it("водителю тут делать нечего", async () => {
useAuth.getState().setSession("t", 42);
wrap();
expect(screen.getByText(/Учебная оплата недоступна/)).toBeInTheDocument();
expect(driver.mockPayOutcome).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,77 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { apiErrorText, mockPayOutcome } from "@/api/driver";
import { useAuth } from "@/store/auth";
import { Spinner } from "@/components/Spinner";
/**
* Учебная «касса»: чем закончится платёж, который сотрудник создал под просмотром.
*
* Это подмена банка, а не экрана приложения. Настоящий экран оплаты
* (`/pay/:orderId`) остаётся тем же самым и разбирает те же статусы — иначе
* отладка проверяла бы поведение, которого у водителя не будет.
*/
const OUTCOMES = [
{ key: "paid", label: "Оплата прошла", hint: "PAID — экран «Оплачено»" },
{ key: "rejected", label: "Банк отклонил", hint: "REJECTED — «Оплата не прошла»" },
{ key: "expired", label: "Счёт протух", hint: "DEADLINE_EXPIRED — «Срок оплаты истёк»" },
{ key: "reversed", label: "Деньги вернулись", hint: "REVERSED — «Платёж возвращён»" },
] as const;
export function MockPayPage() {
const { orderId = "" } = useParams();
const nav = useNavigate();
const impersonated = useAuth((s) => s.impersonated);
const [busy, setBusy] = useState<string | null>(null);
async function choose(outcome: (typeof OUTCOMES)[number]["key"]) {
setBusy(outcome);
try {
await mockPayOutcome(orderId, outcome);
nav(`/pay/${orderId}`, { replace: true });
} catch (e: unknown) {
toast.error(await apiErrorText(e, "Не удалось разыграть исход"));
} finally {
setBusy(null);
}
}
if (!impersonated) {
return (
<div className="pt-24 text-center">
<p className="text-sm font-semibold mb-1">Учебная оплата недоступна</p>
<p className="text-muted text-xs mb-6">Этот экран работает только при просмотре сотрудником.</p>
<button className="btn-ghost" onClick={() => nav("/")}>На главную</button>
</div>
);
}
return (
<div className="pt-8">
<button className="text-muted text-xs mb-3" onClick={() => nav(-1)}> Назад</button>
<h1 className="text-lg font-extrabold mb-1">Учебная оплата</h1>
<p className="text-muted text-xs mb-5">
Банка в этом платеже нет: ни списания, ни чека, ни проводки. Выберите, что
«ответит» банк приложение покажет ровно то же, что увидел бы водитель.
</p>
<div className="card divide-y divide-line">
{OUTCOMES.map((o) => (
<button
key={o.key}
disabled={busy !== null}
onClick={() => choose(o.key)}
className="w-full text-left px-4 py-3 flex items-center justify-between gap-3 disabled:opacity-50"
>
<span>
<span className="block text-sm font-semibold">{o.label}</span>
<span className="block text-muted text-xs">{o.hint}</span>
</span>
{busy === o.key && <Spinner />}
</button>
))}
</div>
<p className="text-muted text-xs mt-4">Заказ: {orderId}</p>
</div>
);
}
+11 -1
View File
@@ -22,6 +22,9 @@ export function PaymentPage() {
const st = (useLocation().state as
{ payUrl?: string; total?: number; bucket?: string; base?: number; method?: string } | null) ?? {};
const { data, lastFailed, failStreak, refetch } = usePaymentStatus(orderId);
// Учебный платёж узнаётся по номеру заказа, а не по state роутера: state
// теряется при перезагрузке, а префикс — нет (см. backend mock_pay).
const mock = orderId.startsWith("mock-");
const status = data?.status ?? "";
const paid = status === "PAID";
// пока метод не передан в state — узнаём по ссылке: СБП отдаёт платёжку qr.nspk.ru, карта — форму банка
@@ -106,7 +109,14 @@ export function PaymentPage() {
{st.total != null && <p className="text-lg font-extrabold">{formatMoney(st.total)}</p>}
{st.bucket && <p className="text-muted text-xs">{st.bucket}</p>}
</div>
{st.payUrl ? (
{mock ? (
<>
<button className="btn-primary" onClick={() => nav(`/mock-pay/${orderId}`)}>
Выбрать исход
</button>
<p className="text-muted text-xs mt-2">Учебный платёж банк не задействован</p>
</>
) : st.payUrl ? (
<>
<a className="btn-primary no-underline" href={st.payUrl} target="_blank" rel="noopener">Оплатить</a>
<p className="text-muted text-xs mt-2">Откроется ваше приложение банка</p>
+7 -1
View File
@@ -41,7 +41,13 @@ export function TopupPage() {
setBusy(true);
try {
const r = await topup(bucket, base, method as "qr" | "card");
nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base } });
// Учебный платёж (сотрудник смотрит от чужого лица): банка нет, платить
// негде — сразу на экран выбора исхода, дальше всё как у водителя.
if (r.mock) {
nav(`/mock-pay/${r.order_id}`, { state: { total: r.total, bucket, base, method } });
return;
}
nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base, method } });
} catch (err: unknown) {
// Текст с сервера, а не глухое «не удалось»: минимум СБП, потолок суммы и
// неизвестная категория объясняются словами, иначе экран молча не реагирует.
+21 -4
View File
@@ -1,10 +1,19 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface AuthState {
/** Кем открыт сеанс: сам водитель или сотрудник, смотрящий от его лица.
* Признак приезжает из токена (claim `imp`), здесь он только продублирован для
* интерфейса — проверять его на бэкенде по этому полю нельзя и не нужно. */
export interface ViewAs {
impersonated: boolean;
/** ФИО водителя — показывать в плашке. У обычного входа неизвестно и не нужно. */
driverName: string | null;
}
interface AuthState extends ViewAs {
token: string | null;
driverId: number | null;
setSession: (token: string, driverId: number) => void;
setSession: (token: string, driverId: number, view?: Partial<ViewAs>) => void;
clear: () => void;
}
@@ -13,8 +22,16 @@ export const useAuth = create<AuthState>()(
(set) => ({
token: null,
driverId: null,
setSession: (token, driverId) => set({ token, driverId }),
clear: () => set({ token: null, driverId: null }),
impersonated: false,
driverName: null,
setSession: (token, driverId, view) =>
set({
token,
driverId,
impersonated: view?.impersonated ?? false,
driverName: view?.driverName ?? null,
}),
clear: () => set({ token: null, driverId: null, impersonated: false, driverName: null }),
}),
{ name: "pp-driver-auth" }
)
+29
View File
@@ -0,0 +1,29 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
/**
* Сеанс СОТРУДНИКА — учётка CRM, которой берут просмотр от лица водителя.
*
* Хранится отдельно от водительского: это два разных токена с разным смыслом.
* «Перестать смотреть за водителя» обязано оставлять сотрудника залогиненным,
* иначе смена водителя каждый раз означала бы новый ввод пароля; и наоборот —
* `clear()` водительского сеанса не должен трогать сотрудника.
*/
interface StaffState {
token: string | null;
username: string | null;
setStaff: (token: string, username: string) => void;
clear: () => void;
}
export const useStaff = create<StaffState>()(
persist(
(set) => ({
token: null,
username: null,
setStaff: (token, username) => set({ token, username }),
clear: () => set({ token: null, username: null }),
}),
{ name: "pp-driver-staff" }
)
);
+5
View File
@@ -7,3 +7,8 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv;
}
// Подставляются на сборке (vite.config.ts → define). Нужны экрану отладки:
// показывают, какой именно бандл сейчас открыт.
declare const __BUILD_COMMIT__: string;
declare const __BUILD_TIME__: string;