diff --git a/driver-pwa/frontend/README.md b/driver-pwa/frontend/README.md index 2439219..4233ffb 100644 --- a/driver-pwa/frontend/README.md +++ b/driver-pwa/frontend/README.md @@ -92,12 +92,13 @@ npm run build # tsc -b && vite build → dist/ `../../docs/superpowers/plans/2026-06-18-premium-driver-pwa.md`, раздел «Deployment notes»: статика на `app.pptaxi.ru`, Caddy с фолбэком на `index.html`. -## Обкатка: кто может войти и как смотреть чужой экран +## Кто может войти и как смотреть чужой экран -Приложение выложено на боевой домен, а обкатывается интерфейс. Поэтому на время -обкатки включены две вещи. +> **Запущено 22.08.2026: вход открыт всем водителям.** Список номеров ниже пуст, +> то есть гейт выключен. Раздел оставлен как инструкция на случай, если вход +> снова понадобится сузить — например под обкатку следующей крупной правки. -### 1. Вход только по списку номеров +### 1. Вход только по списку номеров (сейчас выключено) Переменная бэкенда (`crm2/backend/.env`, читается в `app/config.py`): diff --git a/driver-pwa/frontend/src/api/client.test.ts b/driver-pwa/frontend/src/api/client.test.ts new file mode 100644 index 0000000..2411750 --- /dev/null +++ b/driver-pwa/frontend/src/api/client.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { api } from "./client"; +import { useAuth } from "@/store/auth"; + +/** + * Продление сеанса: бэкенд кладёт свежий токен в заголовок `X-Driver-Token`, + * приложение обязано молча его подменить. Без этого 30 дней жизни токена + * считались бы от входа, и раз в месяц водителя выкидывало бы на экран входа — + * при том, что пароля у него нет и «войти» означает сходить в бота за кодом. + */ +function answer(headers: Record = {}) { + return vi.fn(async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json", ...headers }, + })); +} + +describe("продление токена водителя", () => { + beforeEach(() => { + useAuth.setState({ token: "OLD", driverId: 7, impersonated: false, driverName: null, own: null }); + }); + afterEach(() => vi.unstubAllGlobals()); + + it("подменяет токен, когда ответ принёс свежий", async () => { + vi.stubGlobal("fetch", answer({ "X-Driver-Token": "NEW" })); + await api.get("driver/balance").json(); + expect(useAuth.getState().token).toBe("NEW"); + expect(useAuth.getState().driverId).toBe(7); + }); + + it("без заголовка ничего не трогает", async () => { + vi.stubGlobal("fetch", answer()); + await api.get("driver/balance").json(); + expect(useAuth.getState().token).toBe("OLD"); + }); + + it("не продлевает просмотр от лица водителя", async () => { + // Восемь часов просмотра — сознательный предел для чужого баланса на чужом + // ноутбуке. Бэкенд заголовок и не пришлёт, но полагаться на это нельзя: + // ответ на запрос, ушедший ДО начала просмотра, может прийти уже после. + useAuth.setState({ token: "IMP", driverId: 9, impersonated: true, driverName: "Петров" }); + vi.stubGlobal("fetch", answer({ "X-Driver-Token": "NEW" })); + await api.get("driver/balance").json(); + expect(useAuth.getState().token).toBe("IMP"); + }); + + it("не воскрешает сеанс, закрытый пока запрос был в пути", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { + useAuth.getState().clear(); // водитель нажал «выйти» + return new Response("{}", { status: 200, headers: { "X-Driver-Token": "NEW" } }); + })); + await api.get("driver/balance").json(); + expect(useAuth.getState().token).toBeNull(); + }); +}); diff --git a/driver-pwa/frontend/src/api/client.ts b/driver-pwa/frontend/src/api/client.ts index eaabc94..4af4750 100644 --- a/driver-pwa/frontend/src/api/client.ts +++ b/driver-pwa/frontend/src/api/client.ts @@ -7,6 +7,29 @@ export const API_BASE = import.meta.env.VITE_API_BASE ?? "https://crm.pptaxi.ru/ /** Засечка времени на конкретный запрос. WeakMap — чтобы не держать Request'ы. */ const started = new WeakMap(); +/** + * Заголовок, которым бэкенд отдаёт ПРОДЛЁННЫЙ токен водителя (см. driver_auth.py). + * + * Токен живёт 30 дней, и без продления они считались бы от входа: раз в месяц + * водителя выкидывало бы на экран входа, а «войти» для него означает сходить в + * бота за кодом. Начиная с пятого дня жизни токена бэкенд кладёт в ответ свежий, + * мы его молча подменяем — и 30 дней начинают считаться от последнего захода. + */ +const RENEWED_TOKEN_HEADER = "X-Driver-Token"; + +/** Подменить токен на продлённый, если ответ его принёс. */ +function adoptRenewedToken(request: Request, response: Response): void { + const fresh = response.headers.get(RENEWED_TOKEN_HEADER); + if (!fresh) return; + const auth = useAuth.getState(); + // Сеанс мог смениться, пока запрос был в пути: водитель вышел, сотрудник начал + // просмотр от чужого лица. Подменяем только тот токен, с которым сами ушли, — + // иначе продление чужого ответа вернёт закрытый сеанс к жизни. + if (auth.impersonated || auth.driverId == null) return; + if (request.headers.get("Authorization") !== `Bearer ${auth.token}`) return; + auth.setSession(fresh, auth.driverId); +} + export const api: KyInstance = ky.create({ prefixUrl: API_BASE, timeout: 30_000, @@ -30,6 +53,7 @@ export const api: KyInstance = ky.create({ status: response.status, ms: Math.round(performance.now() - (started.get(request) ?? performance.now())), }); + adoptRenewedToken(request, response); }, ], beforeError: [