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
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:
commit
69fd0ba260
3 changed files with 120 additions and 1 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue