From a3628843c3b6b6a84de2aadd7111eaf9a8c47900 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 00:13:46 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/ui):=20=D0=B2=D1=85=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=B2=20=C2=AB=D0=9A=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=83=C2=BB?= =?UTF-8?q?=20=D0=B8=D0=B7=20v2-=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20+=20=D0=BF=D0=BE=D0=BD=D1=8F=D1=82=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20401=20(#2556)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/trade-in/` редиректит на `/trade-in/v2`, а v2-навигация (TopNav.tsx) не знала про team-дашборд вообще — пункт «Команда» был только в legacy Topbar.tsx (NAV_ITEMS), который на v2-страницах не рендерится. Дашборд существовал, но был недостижим кликом. - app/v2/page.tsx: showTeamNavItem — тот же двойной гейт, что и Topbar.NAV_ITEMS "team" (isPathAllowed(/api/v1/team) + роль admin/manager), передаётся в TopNav. - v2/TopNav.tsx: пункт «Команда» в user-меню рядом с «Выйти» (не таб SectionOverlay — /team отдельный роут, а не секция текущей страницы). Второй дефект: team-API требует session-cookie, легаси Caddy-роль (/me 200) через него не проходит → GET /api/v1/team/employees отдаёт 401, UI показывал невнятную красную плашку. app/team/page.tsx теперь ловит 401 отдельно от 403 и уводит на /login?next=/team (тот же redirect-паттерн, что RouteGuard.tsx для 401 от /me; в dev — сообщение с кнопкой «Войти» вместо авто-редиректа, тот же NODE_ENV-гейт что и в RouteGuard). --- tradein-mvp/frontend/src/app/team/page.tsx | 55 ++++++++++++++++++- tradein-mvp/frontend/src/app/v2/page.tsx | 17 ++++++ .../src/components/trade-in/v2/TopNav.tsx | 49 +++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/frontend/src/app/team/page.tsx b/tradein-mvp/frontend/src/app/team/page.tsx index b42ef0da..d425cc6b 100644 --- a/tradein-mvp/frontend/src/app/team/page.tsx +++ b/tradein-mvp/frontend/src/app/team/page.tsx @@ -9,9 +9,19 @@ * доомed 403 round-trip), но HTTPError 403 от самого списка сотрудников тоже * ловится — тот же defense-in-depth паттерн, что и `app/admin/audit/page.tsx` * (прямой заход на URL не крашит страницу, даже если nav её уже прячет). + * + * #2556 fix (эпик #2549, live-audit): team-роуты требуют session-cookie + * (см. team-api.ts header comment) — легаси Caddy-заголовок (`/me` 200 с + * ролью admin/manager) через него НЕ проходит. Юзер, прошедший только Caddy + * basic_auth (без входа в форму `/login`), видел здесь непонятную красную + * плашку «Не удалось выполнить запрос». Ловим именно 401 от employeesQ + * ОТДЕЛЬНО от 403 (403 = «нет прав», это ожидаемо и есть у другой роли; 401 + * = «нет сессии вообще», это решается логином) и уводим на /login?next=/team + * — тот же redirect-паттерн, что RouteGuard.tsx использует для 401 от /me. */ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; import "@/components/trade-in/trade-in.css"; import { NoAccessScreen } from "@/components/auth/NoAccessScreen"; @@ -26,9 +36,11 @@ import { teamErrorMessage, useEmployees } from "@/lib/team-api"; import { useMe } from "@/lib/useMe"; const PAGE_LIMIT = 50; +const TEAM_LOGIN_NEXT = `/login?next=${encodeURIComponent("/team")}`; export default function TeamPage() { const meQ = useMe(); + const router = useRouter(); // Вычисляем ДО useEmployees (не после) — иначе для employee/analyst/pilot // при прямом заходе на /team улетает обречённый GET (401/403) ещё до того, // как ниже отрисуется role-gate. Порядок хуков не меняется — это просто @@ -43,6 +55,23 @@ export default function TeamPage() { const [quotaEmployee, setQuotaEmployee] = useState(null); const [historyEmployee, setHistoryEmployee] = useState(null); + // 401 = легаси Caddy-роль прошла role-gate выше, но team-эндпоинт требует + // session-cookie, которой у юзера нет (см. header comment). Отдельно от 403 + // (та ветка — «роль есть, прав недостаточно», ниже). + const sessionRequired = + employeesQ.error instanceof HTTPError && employeesQ.error.status === 401; + + // Редирект — побочный эффект (нельзя router.push во время рендера), тот же + // паттерн, что RouteGuard.tsx использует для 401 от /me. NODE_ENV-гейт + // зеркалит RouteGuard: в dev без Caddy/логина 401 — обычное дело, авто- + // редирект там мешал бы локальной разработке; вместо него — сообщение с + // кнопкой ниже. + useEffect(() => { + if (!sessionRequired) return; + if (process.env.NODE_ENV !== "production") return; + router.push(TEAM_LOGIN_NEXT); + }, [sessionRequired, router]); + // Роль ещё не загружена — не решаем ничего, RouteGuard уже отрисовал // страницу (401/403 верхнего уровня он ловит сам), здесь только role-gate. if (meQ.isPending) { @@ -60,6 +89,30 @@ export default function TeamPage() { return ; } + if (sessionRequired) { + // Prod: редирект уже запущен эффектом выше — не мигаем error-текстом, + // пока навигация не завершится (тот же приём, что RouteGuard.tsx). + if (process.env.NODE_ENV === "production") return null; + return ( + <> + +
+

Команда

+

+ Войдите в систему, чтобы управлять командой. +

+ + Войти + +
+ + ); + } + // Backend 403 регардлесс роли фронта (напр. session истекла между /me и // /employees) — тот же fallback, что и в admin/audit. if (employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) { diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 7979ac71..fa443482 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -53,6 +53,7 @@ import type { TradeInEstimateInput, } from "@/types/trade-in"; import { HTTPError } from "@/lib/api"; +import { isPathAllowed } from "@/lib/isPathAllowed"; import { useEstimate, useEstimateHistory, @@ -666,6 +667,21 @@ export default function TradeInV2Page() { }; }, [me.data]); + // #2556 (эпик #2549): вход в раздел «Команда» из v2-навигации — раньше + // TopNav (v2) не знал про team-дашборд вообще, он был достижим только из + // legacy Topbar.tsx (пункт в NAV_ITEMS), а `/trade-in/` редиректит на + // `/trade-in/v2` → дашборд существовал, но был недостижим кликом. Тот же + // двойной гейт, что и Topbar.NAV_ITEMS "team": isPathAllowed(scopePath) — + // DB-роль `manager` получает голый `/api/v1/team/**` в allowed_paths (см. + // DB_ROLE_PATHS, tradein-mvp/backend/app/services/auth_session.py) — ПЛЮС + // roleGate поверх, потому что legacy-роль `analyst` (auth/roles.yaml, + // paths: "/**", без явного deny на /api/v1/team) тоже прошла бы + // isPathAllowed, но не должна видеть «Команда» (scope: только admin/manager). + const showTeamNavItem = + me.data != null && + isPathAllowed(me.data.allowed_paths, me.data.deny_paths, "/api/v1/team") && + (me.data.role === "admin" || me.data.role === "manager"); + // Analog price pins for the 01 map, projected from the real estimate. No // estimate → mapMarkers(null) → [] → ParamsPanel renders only the subject pin // (Finding #2: never the static fixture price markers). @@ -978,6 +994,7 @@ export default function TradeInV2Page() { reports={reportsCount ?? 0} user={topNavUser} onLogout={() => logoutMutation.mutate()} + showTeamLink={showTeamNavItem} />
void; + // #2556 (эпик #2549): показывать пункт «Команда» в user-меню — вычисляется + // страницей из useMe() (роль admin/manager + isPathAllowed на /api/v1/team, + // тот же двойной гейт, что и Topbar.tsx NAV_ITEMS "team"). false/undefined + // → пункт не рендерится (роль employee/pilot/analyst/expired или /me ещё + // не загрузился). + showTeamLink?: boolean; } // Neutral fallback when the user prop is absent (loading / unauthenticated). @@ -57,6 +65,24 @@ const menuItemStyle: CSSProperties = { transition: "background .12s", }; +/** Inline Users icon (lucide-react `Users` SVG path, stroke 1.2) — для + пункта «Команда» в user-меню (#2556). Тот же inline-SVG паттерн (15x15, + tokens.muted), что и остальные иконки этого дропдауна. */ +function UsersIcon() { + return ( + + ); +} + // Профиль / Настройки have no pages yet — render them dimmed and // non-interactive (no hover class, default cursor) so they read as disabled. // «Помощь» used to be in this group too, then linked out to the Telegram @@ -74,6 +100,7 @@ export default function TopNav({ reports = 0, user, onLogout, + showTeamLink = false, }: TopNavProps) { const [userOpen, setUserOpen] = useState(false); const { openChat } = useSupportChat(); @@ -489,6 +516,28 @@ export default function TopNav({ Помощь + {/* «Команда» (#2556, эпик #2549) — team-дашборд (`/team`) отдельный + роут, не таб SectionOverlay, поэтому это ссылка в user-меню + рядом с «Выйти», а не пункт в tnav-tab-ряду выше: v2-табы — + это разделы ОДНОЙ страницы (переключают `active`/onNavigate), + а «Команда» уводит на другой URL. Гейт (showTeamLink) считает + страница из useMe() — здесь только рендер. */} + {showTeamLink && ( + setUserOpen(false)} + > + + Команда + + )} +