gendesign/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx
bot-frontend b175ea4225
All checks were successful
Deploy Trade-In / build-frontend (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 56s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
fix(tradein): санитизация brand.logo_url + валидация brand-цветов (#766) (#791)
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local>
Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
2026-05-30 16:58:14 +00:00

137 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/* eslint-disable @next/next/no-img-element -- white-label логотип хотлинком с сайта клиента, next/image не нужен */
import { UserMenu } from "@/components/auth/UserMenu";
import { API_BASE_URL, HTTPError } from "@/lib/api";
import { isPathAllowed } from "@/lib/isPathAllowed";
import { safeUrl } from "@/lib/safeUrl";
import { useBrand } from "@/lib/useBrand";
import { useMe } from "@/lib/useMe";
type ActiveTab =
| "estimate"
| "history"
| "cache"
| "avito"
| "cian"
| "yandex"
| "cian-cookies";
interface TopbarProps {
active: ActiveTab;
}
/**
* Nav items с двумя путями:
* `href` — относительный путь, который рендерится в URL (basePath
* `/trade-in` префиксится через `API_BASE_URL`).
* `scopePath` — абсолютный путь как его видит backend RBAC config
* (`auth/roles.yaml`).
*
* Caveat / UX-opinion: pages вида `/trade-in/scrapers/*` сами по себе НЕ
* в `pilot.deny` (там только `/trade-in/api/v1/admin/**`). По букве yaml
* pilot имеет право видеть и UI скраперов — но это admin-функционал, и
* рендерить «битый» dashboard с 403-ошибками на каждый data-fetch — плохой
* UX. Поэтому для фильтрации Topbar мы маппим scraper-таб на admin-API
* путь, чтобы pilot их не видел в навигации. Direct URL access на
* `/trade-in/scrapers/avito` НЕ блокируется (RouteGuard следует yaml). Если
* нужна полная блокировка — добавить `/trade-in/scrapers/**` в pilot.deny.
*/
const NAV_ITEMS: Array<{
key: ActiveTab;
href: string;
scopePath: string;
label: string;
}> = [
{ key: "estimate", href: "/", scopePath: "/trade-in/", label: "Оценка" },
{ key: "history", href: "/history", scopePath: "/trade-in/history", label: "История" },
{ key: "cache", href: "/cache", scopePath: "/trade-in/cache", label: "Кэш" },
// Скраперы — admin-only UI. Маппим на admin-deny path, чтобы pilot их не видел.
{
key: "avito",
href: "/scrapers/avito",
scopePath: "/trade-in/api/v1/admin/scrapers/avito",
label: "Авито",
},
{
key: "cian",
href: "/scrapers/cian",
scopePath: "/trade-in/api/v1/admin/scrapers/cian",
label: "Циан",
},
{
key: "yandex",
href: "/scrapers/yandex",
scopePath: "/trade-in/api/v1/admin/scrapers/yandex",
label: "Яндекс",
},
{
key: "cian-cookies",
href: "/scrapers/cian-cookies",
scopePath: "/trade-in/api/v1/admin/scrapers/cian-cookies",
label: "Cian Cookies",
},
];
export function Topbar({ active }: TopbarProps) {
const { data, error } = useMe();
// #657 white-label: при активном ?brand= с логотипом рендерим логотип
// клиента вместо «TI»-метки. Без бренда brand === null → стандартный wordmark.
const { data: brand } = useBrand();
// 401 (dev без Caddy basic_auth) — показываем все айтемы.
// Если /me ещё грузится или дал ошибку без scope — fallback на все айтемы
// (RouteGuard в этом случае сам решит, рендерить ли страницу вообще).
const isDev401 = error instanceof HTTPError && error.status === 401;
const items =
data && !isDev401
? NAV_ITEMS.filter((item) =>
isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath),
)
: NAV_ITEMS;
return (
<header className="topbar">
<div className="topbar-inner">
<div className="brand">
{brand ? (
<>
{/* logo_url из бренд-конфига → safeUrl (блок javascript:/data: схем, #766).
Невалидный URL → fallback на TI-марку, без broken/опасного img. */}
{safeUrl(brand.logo_url) ? (
<img
src={safeUrl(brand.logo_url) as string}
alt={brand.name}
className="brand-logo"
/>
) : (
<span className="brand-mark">TI</span>
)}
<span className="brand-product">{brand.name}</span>
</>
) : (
<>
<span className="brand-mark">TI</span>
<span className="brand-product">Trade-In</span>
</>
)}
</div>
<nav className="top-nav">
{items.map((item) => (
<a
key={item.key}
href={`${API_BASE_URL}${item.href}`}
className={item.key === active ? "is-active" : undefined}
>
{item.label}
</a>
))}
{/* UserMenu — личный кабинет справа от nav. В dev (401) или до /me
UserMenu сам рендерит null. */}
<UserMenu />
</nav>
</div>
</header>
);
}