Merge pull request 'fix(tradein/ui): вход в «Команду» из v2-навигации + понятный 401 (#2556)' (#2570) from fix/tradein-team-nav-and-401 into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m23s
Deploy Trade-In / deploy (push) Successful in 1m3s

This commit is contained in:
lekss361 2026-07-30 21:16:23 +00:00
commit 69fd0ba260
3 changed files with 120 additions and 1 deletions

View file

@ -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<Employee | null>(null);
const [historyEmployee, setHistoryEmployee] = useState<Employee | null>(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 <NoAccessScreen variant="user" />;
}
if (sessionRequired) {
// Prod: редирект уже запущен эффектом выше — не мигаем error-текстом,
// пока навигация не завершится (тот же приём, что RouteGuard.tsx).
if (process.env.NODE_ENV === "production") return null;
return (
<>
<Topbar active="team" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<h1 className="scraper-h1">Команда</h1>
<p className="scraper-result scraper-result--error">
Войдите в систему, чтобы управлять командой.
</p>
<a
href={TEAM_LOGIN_NEXT}
className="team-btn-primary"
style={{ display: "inline-block", textDecoration: "none" }}
>
Войти
</a>
</main>
</>
);
}
// Backend 403 регардлесс роли фронта (напр. session истекла между /me и
// /employees) — тот же fallback, что и в admin/audit.
if (employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) {

View file

@ -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}
/>
</nav>
<main

View file

@ -10,6 +10,8 @@
import { useState } from "react";
import type { CSSProperties } from "react";
import { API_BASE_URL } from "@/lib/api";
import { tokens } from "./tokens";
import { navLabels, version } from "./fixtures";
import { useSupportChat } from "./SupportChatContext";
@ -35,6 +37,12 @@ interface TopNavProps {
user?: TopNavUser;
// Sign out — page wires this to logout(); closes the menu first.
onLogout?: () => 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 (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<circle cx="5.3" cy="5" r="2.2" stroke={tokens.muted} strokeWidth="1.2" />
<path
d="M1 13c0-2.6 1.9-3.9 4.3-3.9S9.6 10.4 9.6 13"
stroke={tokens.muted}
strokeWidth="1.2"
/>
<path d="M9.3 1.8a2.1 2.1 0 0 1 0 4" stroke={tokens.muted} strokeWidth="1.2" />
<path d="M11 9.5c1.9.4 3 1.6 3 3.5" stroke={tokens.muted} strokeWidth="1.2" />
</svg>
);
}
// Профиль / Настройки 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({
Помощь
</button>
{/* «Команда» (#2556, эпик #2549) team-дашборд (`/team`) отдельный
роут, не таб SectionOverlay, поэтому это ссылка в user-меню
рядом с «Выйти», а не пункт в tnav-tab-ряду выше: v2-табы
это разделы ОДНОЙ страницы (переключают `active`/onNavigate),
а «Команда» уводит на другой URL. Гейт (showTeamLink) считает
страница из useMe() здесь только рендер. */}
{showTeamLink && (
<a
role="menuitem"
href={`${API_BASE_URL}/team`}
className="tnav-menuitem"
style={{
...menuItemStyle,
textDecoration: "none",
}}
onClick={() => setUserOpen(false)}
>
<UsersIcon />
Команда
</a>
)}
<div
style={{
height: "1px",