Продление сессии: приложение подхватывает свежий токен из ответа
Бэкенд с пятого дня жизни токена кладёт продлённый в заголовок X-Driver-Token (crm2, modules/payments/driver_auth). Здесь — подмена: тридцать дней начинают считаться от последнего захода, а не от входа, и водитель перестаёт раз в месяц упираться в экран входа. Подменяем только тот токен, с которым сами ушли. Сеанс мог смениться, пока запрос был в пути — водитель вышел, сотрудник начал просмотр от чужого лица, — и продление чужого ответа вернуло бы закрытый сеанс к жизни. Просмотр от лица водителя не продлеваем: его восемь часов сознательные. README: приложение запущено для всех 22.08.2026, гейт по номерам снят. Раздел оставлен как инструкция на случай, если вход снова понадобится сузить. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`):
|
||||
|
||||
|
||||
@@ -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<string, string> = {}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,29 @@ export const API_BASE = import.meta.env.VITE_API_BASE ?? "https://crm.pptaxi.ru/
|
||||
/** Засечка времени на конкретный запрос. WeakMap — чтобы не держать Request'ы. */
|
||||
const started = new WeakMap<Request, number>();
|
||||
|
||||
/**
|
||||
* Заголовок, которым бэкенд отдаёт ПРОДЛЁННЫЙ токен водителя (см. 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: [
|
||||
|
||||
Reference in New Issue
Block a user