From ceec3db364ca2f04734da387728b01d4e46664f5 Mon Sep 17 00:00:00 2001 From: vladtechno Date: Tue, 18 Aug 2026 13:15:02 +1000 Subject: [PATCH] =?UTF-8?q?feat(pwa):=20=D0=B2=D1=85=D0=BE=D0=B4=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=81=D0=BE=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0,=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82?= =?UTF-8?q?=D1=80=20=D0=BE=D1=82=20=D0=BB=D0=B8=D1=86=D0=B0=20=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=D0=B5=D0=BB=D1=8F=20=D0=B8=20=D0=B2=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=20=C2=AB=D0=9E=D1=82=D0=BB=D0=B0?= =?UTF-8?q?=D0=B4=D0=BA=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Приложение обкатывается на боевом контуре, поэтому интерфейс надо проверять на живых данных — а живые данные лежат у настоящих водителей. * «Вход для сотрудника» (/dev) — логин учётки CRM, поддержан второй шаг 2FA; дальше поиск водителя по ФИО/телефону/id и просмотр его экранов. * Плашка «Смотрю как …» над всеми экранами: интерфейс в этом режиме неотличим от водительского, и чужой баланс легко принять за свой. * Вкладка «Отладка» (/debug, водителю недоступна): смена водителя, driver_id, режим, срок токена, адрес API, коммит и время сборки, журнал последних 40 ответивших запросов, сброс кэша сервис-воркера. * Пополнение под просмотром идёт в учебный контур: экран /mock-pay/:orderId выбирает, что «ответит» банк, а настоящий экран оплаты разбирает эти статусы своим боевым кодом. Ни рубля, ни чека 54-ФЗ. Заодно доезжает незакоммиченная работа прошлой сессии: дев-прокси в vite.config.ts, его тест и разобранный .env.example. Co-Authored-By: Claude Opus 5 --- driver-pwa/frontend/.env.example | 27 +++- driver-pwa/frontend/README.md | 147 ++++++++++++++++++ driver-pwa/frontend/dev-proxy.test.ts | 103 ++++++++++++ driver-pwa/frontend/src/App.tsx | 20 +++ driver-pwa/frontend/src/api/client.ts | 20 ++- driver-pwa/frontend/src/api/driver.ts | 10 ++ driver-pwa/frontend/src/api/staff.ts | 85 ++++++++++ .../frontend/src/components/DriverPicker.tsx | 86 ++++++++++ .../src/components/ImpersonationBar.tsx | 34 ++++ driver-pwa/frontend/src/lib/jwt.ts | 40 +++++ driver-pwa/frontend/src/lib/reqlog.test.ts | 28 ++++ driver-pwa/frontend/src/lib/reqlog.ts | 55 +++++++ .../frontend/src/pages/DebugPage.test.tsx | 67 ++++++++ driver-pwa/frontend/src/pages/DebugPage.tsx | 127 +++++++++++++++ .../frontend/src/pages/DevLoginPage.test.tsx | 67 ++++++++ .../frontend/src/pages/DevLoginPage.tsx | 122 +++++++++++++++ driver-pwa/frontend/src/pages/LoginPage.tsx | 5 + .../frontend/src/pages/MockPayPage.test.tsx | 42 +++++ driver-pwa/frontend/src/pages/MockPayPage.tsx | 77 +++++++++ driver-pwa/frontend/src/pages/PaymentPage.tsx | 12 +- driver-pwa/frontend/src/pages/TopupPage.tsx | 8 +- driver-pwa/frontend/src/store/auth.ts | 25 ++- driver-pwa/frontend/src/store/staff.ts | 29 ++++ driver-pwa/frontend/src/vite-env.d.ts | 5 + driver-pwa/frontend/tsconfig.node.json | 2 +- driver-pwa/frontend/vite.config.ts | 76 ++++++++- driver-pwa/frontend/vitest.config.ts | 6 + 27 files changed, 1308 insertions(+), 17 deletions(-) create mode 100644 driver-pwa/frontend/README.md create mode 100644 driver-pwa/frontend/dev-proxy.test.ts create mode 100644 driver-pwa/frontend/src/api/staff.ts create mode 100644 driver-pwa/frontend/src/components/DriverPicker.tsx create mode 100644 driver-pwa/frontend/src/components/ImpersonationBar.tsx create mode 100644 driver-pwa/frontend/src/lib/jwt.ts create mode 100644 driver-pwa/frontend/src/lib/reqlog.test.ts create mode 100644 driver-pwa/frontend/src/lib/reqlog.ts create mode 100644 driver-pwa/frontend/src/pages/DebugPage.test.tsx create mode 100644 driver-pwa/frontend/src/pages/DebugPage.tsx create mode 100644 driver-pwa/frontend/src/pages/DevLoginPage.test.tsx create mode 100644 driver-pwa/frontend/src/pages/DevLoginPage.tsx create mode 100644 driver-pwa/frontend/src/pages/MockPayPage.test.tsx create mode 100644 driver-pwa/frontend/src/pages/MockPayPage.tsx create mode 100644 driver-pwa/frontend/src/store/staff.ts diff --git a/driver-pwa/frontend/.env.example b/driver-pwa/frontend/.env.example index 2e931cc..926f96b 100644 --- a/driver-pwa/frontend/.env.example +++ b/driver-pwa/frontend/.env.example @@ -1 +1,26 @@ -VITE_API_BASE=https://crm.pptaxi.ru/api +# Премиум Водитель (PWA) — переменные сборки. +# Как поднять фронт локально — README.md рядом с этим файлом. + +# --- VITE_API_BASE: база API для ky (src/api/client.ts) ---------------------- +# +# Боевой сборке задавать её НЕ обязательно: если переменной нет, client.ts +# подставляет https://crm.pptaxi.ru/api. Поэтому строка ниже закомментирована +# намеренно — скопированный в .env боевой адрес сборке ничего не добавляет, +# а локальную разработку ломает: значение из файла приоритетнее дев-дефолта, +# и `npm run dev` начнёт бить в боевой терминал вместо локального бэкенда. +# +#VITE_API_BASE=https://crm.pptaxi.ru/api + +# --- Локальная разработка ---------------------------------------------------- +# +# Копировать этот файл в .env не нужно. `npm run dev` сам ставит +# VITE_API_BASE=/api и проксирует /api на локальный бэкенд (vite.config.ts), +# то есть запрос идёт с того же origin, что и страница, и CORS не участвует — +# CORS_ALLOW_LOCALHOST в backend/.env для этого фронта не нужен. +# +# Свой адрес бэкенда, поход в API мимо прокси, PowerShell-вариант команд — +# в README.md. Рецепты держим в одном месте: разъедутся — поверят не тому. +# +# ВНИМАНИЕ: crm.pptaxi.ru — боевой терминал Т-Банка и боевая касса АТОЛ. +# Пополнение, сделанное «просто посмотреть», — это реальные деньги водителя, +# реальный чек 54-ФЗ и реальная проводка в леджере. diff --git a/driver-pwa/frontend/README.md b/driver-pwa/frontend/README.md new file mode 100644 index 0000000..2439219 --- /dev/null +++ b/driver-pwa/frontend/README.md @@ -0,0 +1,147 @@ +# Премиум Водитель — PWA водителя + +React + Vite + TypeScript. Прод: **https://app.pptaxi.ru**. +Бэкенд — модуль `payments` в Premium CRM (`https://crm.pptaxi.ru/api`, +репозиторий `crm2`, `backend/app/modules/payments/`). + +## Как поднять локально + +```bash +npm install +npm run dev # http://localhost:5174 +``` + +Отдельно поднимается бэкенд `crm2` на **127.0.0.1:8000** (тот же порт, что в +`backend/Dockerfile`). Больше настраивать нечего: dev-сервер сам подставляет +`VITE_API_BASE=/api` и проксирует `/api` на бэкенд — см. `vite.config.ts`. + +**Почему через прокси, а не «разрешить localhost в CORS бэкенда».** Прокси +делает запрос ОДНОГО происхождения со страницей (`http://localhost:5174/api/…`), +поэтому CORS в разработке не участвует вообще. Боевой список origin'ов на +бэкенде остаётся закрытым, а `CORS_ALLOW_LOCALHOST=true` в `backend/.env` для +этого фронта включать не надо. (Раньше рецепт с `CORS_ALLOW_LOCALHOST` тут и не +работал: прокси не было, и фронт по умолчанию уходил на боевой домен, а не на +локальный бэкенд.) + +Сервис-воркер в режиме разработки не регистрируется (`devOptions` у +`vite-plugin-pwa` по умолчанию выключены) — старый кэш прод-версии в дев не +лезет. + +### Бэкенд не на `127.0.0.1:8000` + +```bash +VITE_API_TARGET=http://127.0.0.1:8090 npm run dev +``` + +В PowerShell инлайн-префикса `VAR=... команда` нет — переменную ставят отдельно: + +```powershell +$env:VITE_API_TARGET="http://127.0.0.1:8090"; npm run dev +``` + +Имя переменной то же, что в CRM (`crm2/frontend/vite.config.ts`), — рецепт один +на оба фронта. + +### Ходить в API напрямую, мимо прокси + +`.env.local` (в git не попадает): + +``` +VITE_API_BASE=http://127.0.0.1:8000/api +``` + +Своя настройка приоритетнее дев-дефолта — специально, чтобы `npm run dev` её не +перебивал. Для похода в чужой origin бэкенду понадобится +`CORS_ALLOW_LOCALHOST=true` в `backend/.env` и рестарт. + +> ⚠️ `crm.pptaxi.ru` — боевой терминал Т-Банка и боевая касса АТОЛ. Пополнение, +> сделанное «просто посмотреть», — это реальные деньги водителя, реальный чек +> 54-ФЗ и реальная проводка в леджере. Поэтому прокси по умолчанию смотрит в +> локальный бэкенд, а не в прод. + +## Проверки + +```bash +npx tsc -b # типы +npm test # vitest run +``` + +`npx tsc --noEmit` **без `-b`** здесь всегда зелёный и ничего не значит: +корневой `tsconfig.json` — solution-файл (`files: []` + `references`), под него +не попадает ни один файл (`tsc --noEmit --listFiles` печатает пустоту). Типы +живут в `tsconfig.app.json` (`src`) и `tsconfig.node.json` (конфиги + +`dev-proxy.test.ts`), и до обоих добирается только `tsc -b`. + +## Сборка + +```bash +npm run build # tsc -b && vite build → dist/ +``` + +Дев-дефолт `VITE_API_BASE=/api` подставляется только при запущенном dev-сервере +и в бандл не попадает: в собранном `assets/index-*.js` лежит +`https://crm.pptaxi.ru/api` (проверено сборкой). Поэтому `VITE_API_BASE` боевой +сборке задавать не обязательно — без неё `src/api/client.ts` подставляет ровно +этот адрес. + +> ⚠️ `npm run preview` поднимает **уже собранный** бандл, а база в нём +> абсолютная: запросы уходят на боевой `crm.pptaxi.ru` мимо прокси dev-сервера. +> Смотреть локальные правки — только `npm run dev`. + +Куда выкладывается `dist/` — в плане проекта: +`../../docs/superpowers/plans/2026-06-18-premium-driver-pwa.md`, раздел +«Deployment notes»: статика на `app.pptaxi.ru`, Caddy с фолбэком на `index.html`. + +## Обкатка: кто может войти и как смотреть чужой экран + +Приложение выложено на боевой домен, а обкатывается интерфейс. Поэтому на время +обкатки включены две вещи. + +### 1. Вход только по списку номеров + +Переменная бэкенда (`crm2/backend/.env`, читается в `app/config.py`): + +``` +DRIVER_APP_ALLOWED_PHONES=+7 (914) 123-45-67, 79990001122 +``` + +Разделители — запятая, точка с запятой, перенос строки (пробел разделителем не +считается: номер вписывают в привычном виде). Сверяются последние 10 цифр. +**Пусто — гейт выключен, вход открыт всем водителям.** + +Проверка стоит в единственной воронке выдачи токена +(`modules/payments/driver_auth.create_driver_token_for`), поэтому закрыты сразу +все пять путей входа: код в бот, кнопка «Войти через Telegram», колбэки своего +бота и два аварийных отката на n8n. Бот отвечает человеку текстом, остальные +пути — 403. Логика гейта: `modules/payments/login_gate.py`. + +### 2. Просмотр от лица водителя (сотрудник) + +На экране входа внизу — «Вход для сотрудника» (`/dev`): логин и пароль **учётки +CRM**, при включённой двухфакторке спросит код. Дальше поиск по ФИО, телефону +или id — и приложение открывается от лица выбранного водителя. + +* Токен помечен claim'ом `imp`, живёт 8 часов (обычный водительский — 30 дней). +* Гейт — `is_admin` в CRM. Каждая выдача пишется в аудит (`driver_app.impersonate`). +* Кнопка «Отозвать доступ» в CRM гасит и просмотр сотрудника — поколение общее. +* Гейт обкатки на этот путь не распространяется: смотреть надо как раз на + водителей, которых в allowlist нет. + +Пока просмотр действует, сверху на всех экранах висит плашка «Смотрю как …» со +ссылкой на вкладку **«Отладка»** (`/debug`, водителю недоступна): + +* от чьего лица открыт экран и кнопка сменить водителя; +* `driver_id`, режим, сотрудник, когда истекает токен, адрес API, коммит и время + сборки, состояние сети; +* журнал последних 40 ответивших запросов (метод, путь, статус, миллисекунды); +* сброс кэша сервис-воркера с перезагрузкой — лечит «правка не приехала»; +* выходы: из просмотра, из учётки сотрудника. + +### Деньги под просмотром: учебный платёж + +Пополнение под `imp`-токеном **не идёт в банк**. Проверки суммы, категории и +расчёт комиссии настоящие, а вместо Init на терминале заводится учебный заказ в +Redis (`modules/payments/mock_pay.py`, префикс `mock-`, час жизни). Экран +`/mock-pay/:orderId` даёт выбрать, что «ответит» банк — оплачено, отказ, +истёкший счёт, возврат, — и настоящий экран оплаты разбирает эти статусы своим +боевым кодом. В `driver_payments` не попадает ни строки, чек 54-ФЗ не пробивается. diff --git a/driver-pwa/frontend/dev-proxy.test.ts b/driver-pwa/frontend/dev-proxy.test.ts new file mode 100644 index 0000000..95d21f9 --- /dev/null +++ b/driver-pwa/frontend/dev-proxy.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment node +/** + * Локальная разработка PWA водителя. + * + * Гейт CORS на бэкенде (CORS_ALLOW_LOCALHOST, по умолчанию выключен) закрыл + * дыру, но заодно оставил этот фронт без рабочего дева: прокси не было, а + * client.ts по умолчанию уходит на боевой https://crm.pptaxi.ru/api — то есть + * «включи CORS_ALLOW_LOCALHOST» тут не помогало, запрос вообще не доходил до + * локального бэкенда. + * + * Тесты держат обе половины договорённости: + * 1) `npm run dev` доводит фронт до локального бэкенда сам, без правки .env; + * 2) боевая сборка от этого не меняется — относительная дев-база в бандл + * попасть не может (в проде он живёт на app.pptaxi.ru, где /api нет). + * + * Конфиг резолвится по-настоящему (resolveConfig читает vite.config.ts и + * env-файлы) — иначе тест проверял бы не то, что увидит vite. + * + * Файл лежит рядом с vite.config.ts и НЕ называется vite.config.test.ts: + * в дефолтном exclude vitest есть `**\/{...,vite,...}.config.*`, под который + * такое имя попадает целиком — тест молча не запускался бы. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import path from "path"; +import { fileURLToPath } from "url"; +import { resolveConfig } from "vite"; + +const ROOT = path.dirname(fileURLToPath(import.meta.url)); + +/** Обе переменные — process.env, и dev-дефолт их же и трогает: сохраняем. */ +const SAVED = { + base: process.env.VITE_API_BASE, + target: process.env.VITE_API_TARGET, +}; + +function restore(key: "VITE_API_BASE" | "VITE_API_TARGET", value: string | undefined): void { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; +} + +beforeEach(() => { + delete process.env.VITE_API_BASE; + delete process.env.VITE_API_TARGET; +}); + +afterEach(() => { + restore("VITE_API_BASE", SAVED.base); + restore("VITE_API_TARGET", SAVED.target); +}); + +const resolve = (command: "serve" | "build", mode: "development" | "production") => + resolveConfig({ root: ROOT, configFile: path.join(ROOT, "vite.config.ts"), logLevel: "silent" }, + command, mode, mode); + +describe("dev-сервер доводит фронт до локального бэкенда", () => { + it("подставляет относительную базу — запрос идёт со своего origin, CORS не участвует", async () => { + const cfg = await resolve("serve", "development"); + expect(cfg.env.VITE_API_BASE).toBe("/api"); + }); + + it("проксирует /api на локальный бэкенд, а не на боевой домен", async () => { + const cfg = await resolve("serve", "development"); + const proxy = cfg.server.proxy?.["/api"]; + expect(typeof proxy === "object" && proxy.target).toBe("http://127.0.0.1:8000"); + }); + + it("цель прокси переопределяется VITE_API_TARGET (стенд, тайлнет)", async () => { + process.env.VITE_API_TARGET = "http://127.0.0.1:8090"; + const cfg = await resolve("serve", "development"); + const proxy = cfg.server.proxy?.["/api"]; + expect(typeof proxy === "object" && proxy.target).toBe("http://127.0.0.1:8090"); + }); + + it("база и путь прокси — одно и то же", async () => { + // Две половины рецепта живут в разных местах конфига: базу подставляем в + // env, путь перечисляем в server.proxy. Разъедутся (например, база станет + // "/backend") — dev-сервер отдаст на этот путь index.html вместо ответа + // бэкенда, и падать будет уже в разборе JSON, далеко от причины. + const cfg = await resolve("serve", "development"); + expect(Object.keys(cfg.server.proxy ?? {})).toContain(cfg.env.VITE_API_BASE); + }); + + it("не перебивает базу, заданную разработчиком", async () => { + process.env.VITE_API_BASE = "https://stand.example/api"; + const cfg = await resolve("serve", "development"); + expect(cfg.env.VITE_API_BASE).toBe("https://stand.example/api"); + }); +}); + +describe("боевая сборка остаётся боевой", () => { + it("дев-дефолт в сборку не течёт: base пустая → client.ts берёт прод-адрес", async () => { + const cfg = await resolve("build", "production"); + expect(cfg.env.VITE_API_BASE).toBeUndefined(); + expect(process.env.VITE_API_BASE).toBeUndefined(); + }); + + it("ни один env-файл не подсовывает сборке относительную базу", async () => { + // Относительный /api в проде означал бы app.pptaxi.ru/api — такого нет. + // Ловит и коммит .env с дев-значением, и локальный .env на машине сборки. + const cfg = await resolve("build", "production"); + expect(cfg.env.VITE_API_BASE).not.toBe("/api"); + }); +}); diff --git a/driver-pwa/frontend/src/App.tsx b/driver-pwa/frontend/src/App.tsx index 55d0d3b..78bb616 100644 --- a/driver-pwa/frontend/src/App.tsx +++ b/driver-pwa/frontend/src/App.tsx @@ -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} : ; } +/** Отладка существует только для сотрудника: либо он вошёл учёткой CRM, либо + * прямо сейчас смотрит от лица водителя. Обычному водителю адрес /debug ничего + * не даёт — его уводит на главную. */ +function RequireStaff({ children }: { children: ReactNode }) { + const impersonated = useAuth((s) => s.impersonated); + const staffToken = useStaff((s) => s.token); + return impersonated || staffToken ? <>{children} : ; +} + export default function App() { return (
+ {/* Плашка живёт над маршрутами: чей экран открыт, видно на каждом из них, + а не только там, где про неё вспомнили. Сама решает, показываться ли. */} + } /> + } /> } /> } /> } /> } /> } /> + } /> } /> + } /> } />
diff --git a/driver-pwa/frontend/src/api/client.ts b/driver-pwa/frontend/src/api/client.ts index 6e30f47..eaabc94 100644 --- a/driver-pwa/frontend/src/api/client.ts +++ b/driver-pwa/frontend/src/api/client.ts @@ -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(); 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(); diff --git a/driver-pwa/frontend/src/api/driver.ts b/driver-pwa/frontend/src/api/driver.ts index fb59e25..0b5529b 100644 --- a/driver-pwa/frontend/src/api/driver.ts +++ b/driver-pwa/frontend/src/api/driver.ts @@ -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(), +); diff --git a/driver-pwa/frontend/src/api/staff.ts b/driver-pwa/frontend/src/api/staff.ts new file mode 100644 index 0000000..3f67c32 --- /dev/null +++ b/driver-pwa/frontend/src/api/staff.ts @@ -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(); + +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; + +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()); diff --git a/driver-pwa/frontend/src/components/DriverPicker.tsx b/driver-pwa/frontend/src/components/DriverPicker.tsx new file mode 100644 index 0000000..741aa43 --- /dev/null +++ b/driver-pwa/frontend/src/components/DriverPicker.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [busyId, setBusyId] = useState(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 ( +
+
+ + setQ(e.target.value)} + placeholder="ФИО, телефон или id" + className="w-full text-base bg-transparent outline-none" + /> +
+ + {loading ? ( +
+ ) : list.length === 0 ? ( +

Никого не нашли

+ ) : ( +
+ {list.map((d) => ( + + ))} +
+ )} +
+ ); +} diff --git a/driver-pwa/frontend/src/components/ImpersonationBar.tsx b/driver-pwa/frontend/src/components/ImpersonationBar.tsx new file mode 100644 index 0000000..e14e74c --- /dev/null +++ b/driver-pwa/frontend/src/components/ImpersonationBar.tsx @@ -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 ( +
+ + + Смотрю как {driverName ?? `водитель #${driverId}`} + + +
+ ); +} diff --git a/driver-pwa/frontend/src/lib/jwt.ts b/driver-pwa/frontend/src/lib/jwt.ts new file mode 100644 index 0000000..be9865b --- /dev/null +++ b/driver-pwa/frontend/src/lib/jwt.ts @@ -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; + 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} мин`; +} diff --git a/driver-pwa/frontend/src/lib/reqlog.test.ts b/driver-pwa/frontend/src/lib/reqlog.test.ts new file mode 100644 index 0000000..78827c0 --- /dev/null +++ b/driver-pwa/frontend/src/lib/reqlog.test.ts @@ -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"); + }); +}); diff --git a/driver-pwa/frontend/src/lib/reqlog.ts b/driver-pwa/frontend/src/lib/reqlog.ts new file mode 100644 index 0000000..c13a51b --- /dev/null +++ b/driver-pwa/frontend/src/lib/reqlog.ts @@ -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): 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]; + } +} diff --git a/driver-pwa/frontend/src/pages/DebugPage.test.tsx b/driver-pwa/frontend/src/pages/DebugPage.test.tsx new file mode 100644 index 0000000..4f08bcc --- /dev/null +++ b/driver-pwa/frontend/src/pages/DebugPage.test.tsx @@ -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( + + + + ); +} + +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( + + + + ); + expect(screen.getByText("/api/driver/balance")).toBeInTheDocument(); + expect(screen.getByText("401")).toBeInTheDocument(); + expect(screen.getByText("123 мс")).toBeInTheDocument(); + }); +}); diff --git a/driver-pwa/frontend/src/pages/DebugPage.tsx b/driver-pwa/frontend/src/pages/DebugPage.tsx new file mode 100644 index 0000000..56d886e --- /dev/null +++ b/driver-pwa/frontend/src/pages/DebugPage.tsx @@ -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 ( +
+ {k} + {v} +
+ ); +} + +/** + * Вкладка «Отладка» — то, чего в приложении водителя нет и быть не должно: + * от чьего лица открыт экран, куда ходят запросы, чем они отвечают. + * + * Показывается только сотруднику (есть учётка 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 ( +
+

Отладка

+ +
+

Сейчас смотрю

+

+ {impersonated ? (driverName ?? `Водитель #${driverId}`) : "Свой аккаунт водителя"} +

+ + {switching && ( +
+ { setSwitching(false); toast.success(`Смотрю как ${d.fio}`); }} /> +
+ )} +
+ +
+

Сведения

+ + + + + + + +
+ +
+
+

Последние запросы

+ +
+ {log.length === 0 ? ( +

Пока пусто

+ ) : ( +
+ {log.map((e) => ( +
+ = 400 ? "text-neg" : "text-pos"}> + {e.status ?? "—"} + + {e.method} + {e.path} + {e.ms} мс +
+ ))} +
+ )} +

+ Запрос, оборвавшийся без ответа, сюда не попадает. +

+
+ + + + {staff.token && ( + + )} +
+ ); +} diff --git a/driver-pwa/frontend/src/pages/DevLoginPage.test.tsx b/driver-pwa/frontend/src/pages/DevLoginPage.test.tsx new file mode 100644 index 0000000..8f25e2d --- /dev/null +++ b/driver-pwa/frontend/src/pages/DevLoginPage.test.tsx @@ -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(); + +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"); + }); +}); diff --git a/driver-pwa/frontend/src/pages/DevLoginPage.tsx b/driver-pwa/frontend/src/pages/DevLoginPage.tsx new file mode 100644 index 0000000..0d06b2f --- /dev/null +++ b/driver-pwa/frontend/src/pages/DevLoginPage.tsx @@ -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(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 ( +
+

Кого смотрим

+

+ Сотрудник: {staffName}. Приложение откроется от лица выбранного водителя, + деньги в этом режиме не двигаются. +

+ nav("/")} /> + +
+ ); + } + + return ( +
+

Вход для сотрудника

+

Учётная запись Premium CRM

+ + {partial ? ( + <> +

Код из приложения-аутентификатора.

+ 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" + /> + + + + ) : ( + <> + setUsername(e.target.value)} + placeholder="Логин" + className="card w-full px-4 py-3 mb-2 text-base bg-white" + /> + 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" + /> + + + + )} +
+ ); +} diff --git a/driver-pwa/frontend/src/pages/LoginPage.tsx b/driver-pwa/frontend/src/pages/LoginPage.tsx index eb902ee..7e51341 100644 --- a/driver-pwa/frontend/src/pages/LoginPage.tsx +++ b/driver-pwa/frontend/src/pages/LoginPage.tsx @@ -101,6 +101,11 @@ export function LoginPage() { )}

Telegram подтвердит ваш номер автоматически.

+ {/* Пока идёт обкатка, сотруднику надо попадать внутрь без карточки + водителя и без бота — учёткой CRM. Ссылка неприметная намеренно. */} + ) : ( <> diff --git a/driver-pwa/frontend/src/pages/MockPayPage.test.tsx b/driver-pwa/frontend/src/pages/MockPayPage.test.tsx new file mode 100644 index 0000000..1188071 --- /dev/null +++ b/driver-pwa/frontend/src/pages/MockPayPage.test.tsx @@ -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( + + + } /> + экран оплаты

} /> +
+
+ ); + +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(); + }); +}); diff --git a/driver-pwa/frontend/src/pages/MockPayPage.tsx b/driver-pwa/frontend/src/pages/MockPayPage.tsx new file mode 100644 index 0000000..69d254c --- /dev/null +++ b/driver-pwa/frontend/src/pages/MockPayPage.tsx @@ -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(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 ( +
+

Учебная оплата недоступна

+

Этот экран работает только при просмотре сотрудником.

+ +
+ ); + } + + return ( +
+ +

Учебная оплата

+

+ Банка в этом платеже нет: ни списания, ни чека, ни проводки. Выберите, что + «ответит» банк — приложение покажет ровно то же, что увидел бы водитель. +

+
+ {OUTCOMES.map((o) => ( + + ))} +
+

Заказ: {orderId}

+
+ ); +} diff --git a/driver-pwa/frontend/src/pages/PaymentPage.tsx b/driver-pwa/frontend/src/pages/PaymentPage.tsx index e59acbb..0c91869 100644 --- a/driver-pwa/frontend/src/pages/PaymentPage.tsx +++ b/driver-pwa/frontend/src/pages/PaymentPage.tsx @@ -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 &&

{formatMoney(st.total)}

} {st.bucket &&

{st.bucket}

} - {st.payUrl ? ( + {mock ? ( + <> + +

Учебный платёж — банк не задействован

+ + ) : st.payUrl ? ( <> Оплатить

Откроется ваше приложение банка

diff --git a/driver-pwa/frontend/src/pages/TopupPage.tsx b/driver-pwa/frontend/src/pages/TopupPage.tsx index 3dbea2c..6156a25 100644 --- a/driver-pwa/frontend/src/pages/TopupPage.tsx +++ b/driver-pwa/frontend/src/pages/TopupPage.tsx @@ -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) { // Текст с сервера, а не глухое «не удалось»: минимум СБП, потолок суммы и // неизвестная категория объясняются словами, иначе экран молча не реагирует. diff --git a/driver-pwa/frontend/src/store/auth.ts b/driver-pwa/frontend/src/store/auth.ts index f71fd5f..11f4438 100644 --- a/driver-pwa/frontend/src/store/auth.ts +++ b/driver-pwa/frontend/src/store/auth.ts @@ -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) => void; clear: () => void; } @@ -13,8 +22,16 @@ export const useAuth = create()( (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" } ) diff --git a/driver-pwa/frontend/src/store/staff.ts b/driver-pwa/frontend/src/store/staff.ts new file mode 100644 index 0000000..debf6e0 --- /dev/null +++ b/driver-pwa/frontend/src/store/staff.ts @@ -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()( + persist( + (set) => ({ + token: null, + username: null, + setStaff: (token, username) => set({ token, username }), + clear: () => set({ token: null, username: null }), + }), + { name: "pp-driver-staff" } + ) +); diff --git a/driver-pwa/frontend/src/vite-env.d.ts b/driver-pwa/frontend/src/vite-env.d.ts index 7a7f1cd..8e67021 100644 --- a/driver-pwa/frontend/src/vite-env.d.ts +++ b/driver-pwa/frontend/src/vite-env.d.ts @@ -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; diff --git a/driver-pwa/frontend/tsconfig.node.json b/driver-pwa/frontend/tsconfig.node.json index 3b14490..115e6d5 100644 --- a/driver-pwa/frontend/tsconfig.node.json +++ b/driver-pwa/frontend/tsconfig.node.json @@ -4,5 +4,5 @@ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "noEmit": true, "strict": true }, - "include": ["vite.config.ts", "vitest.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts", "dev-proxy.test.ts"] } diff --git a/driver-pwa/frontend/vite.config.ts b/driver-pwa/frontend/vite.config.ts index 04a58dd..3922680 100644 --- a/driver-pwa/frontend/vite.config.ts +++ b/driver-pwa/frontend/vite.config.ts @@ -1,13 +1,73 @@ -import { defineConfig } from "vite"; +import { execSync } from "child_process"; +import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; import { VitePWA } from "vite-plugin-pwa"; import path from "path"; -export default defineConfig({ - plugins: [ - react(), - VitePWA({ registerType: "autoUpdate", manifest: false, includeAssets: ["icons/*.png"] }), - ], - resolve: { alias: { "@": path.resolve(__dirname, "src") } }, - server: { port: 5174 }, +/** + * Что именно сейчас открыто — видно на вкладке «Отладка». + * + * Без этого «правка не приехала» и «приехала, но кэш старый» выглядят + * одинаково, а проверяются по-разному. Коммит берём из git; вне репозитория + * (сборка из архива, CI без .git) — честный «unknown», а не падение сборки. + */ +function buildCommit(): string { + try { + return execSync("git rev-parse --short HEAD", { stdio: ["ignore", "pipe", "ignore"] }) + .toString().trim() || "unknown"; + } catch { + return "unknown"; + } +} + +/** + * Локальная разработка ходит в бэкенд ЧЕРЕЗ ПРОКСИ dev-сервера, а не напрямую. + * + * Почему не «разрешить localhost в CORS бэкенда»: через прокси запрос уходит с + * того же origin, что и страница (http://localhost:5174/api/...), поэтому CORS + * в разработке не участвует вообще — ни preflight, ни credentials. Боевой + * список origin'ов на бэкенде остаётся закрытым, и CORS_ALLOW_LOCALHOST ради + * этого фронта включать не нужно. + * + * Дефолтная цель прокси — ЛОКАЛЬНЫЙ бэкенд, а не crm.pptaxi.ru. Контур боевой: + * «по умолчанию проксировать в прод» означало бы, что первый же тычок в + * «Пополнить» на машине разработчика создаёт платёж на живом терминале и живой + * чек 54-ФЗ. Другой адрес задаётся VITE_API_TARGET — тем же именем, что в CRM + * (crm2/frontend/vite.config.ts), чтобы рецепт был один на оба фронта. + */ +export default defineConfig(({ command, mode }) => { + // База для ky (src/api/client.ts): в dev — свой же origin, дальше прокси. + // Ставим ТОЛЬКО если разработчик не задал базу сам (.env.local, .env, + // VITE_API_BASE=... перед командой): loadEnv отдаёт и файлы, и process.env, + // причём process.env приоритетнее файлов — без этой проверки собственная + // настройка молча перебивалась бы нашим дефолтом. + // В сборку (command === "build") не лезем никогда: там база остаётся боевой — + // из .env, либо из дефолта в client.ts. + if (command === "serve" && !loadEnv(mode, __dirname, "VITE_").VITE_API_BASE) { + process.env.VITE_API_BASE = "/api"; + } + + return { + define: { + __BUILD_COMMIT__: JSON.stringify(buildCommit()), + __BUILD_TIME__: JSON.stringify(new Date().toISOString().slice(0, 16).replace("T", " ")), + }, + plugins: [ + react(), + VitePWA({ registerType: "autoUpdate", manifest: false, includeAssets: ["icons/*.png"] }), + ], + resolve: { alias: { "@": path.resolve(__dirname, "src") } }, + server: { + port: 5174, + proxy: { + "/api": { + // secure: false нужен только для VITE_API_TARGET на https-цель с + // самоподписанным сертификатом; на дефолтную http-цель не влияет. + target: process.env.VITE_API_TARGET || "http://127.0.0.1:8000", + changeOrigin: true, + secure: false, + }, + }, + }, + }; }); diff --git a/driver-pwa/frontend/vitest.config.ts b/driver-pwa/frontend/vitest.config.ts index a2cc072..43a7b9c 100644 --- a/driver-pwa/frontend/vitest.config.ts +++ b/driver-pwa/frontend/vitest.config.ts @@ -3,6 +3,12 @@ import react from "@vitejs/plugin-react"; import path from "path"; export default defineConfig({ + // Те же подстановки, что и в боевой сборке (vite.config.ts): без них модуль с + // __BUILD_COMMIT__ падает в тестах на ReferenceError, хотя в приложении работает. + define: { + __BUILD_COMMIT__: JSON.stringify("test"), + __BUILD_TIME__: JSON.stringify("test"), + }, plugins: [react()], resolve: { alias: { "@": path.resolve(__dirname, "src") } }, test: { globals: true, environment: "jsdom", setupFiles: ["./src/test/setup.ts"] },