From 358824c2846eb4dac735f5ed7704beb3ed4a4f51 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Tue, 26 May 2026 15:48:26 +0500 Subject: [PATCH] =?UTF-8?q?feat(auth):=20UserMenu=20=E2=80=94=20avatar=20+?= =?UTF-8?q?=20dropdown=20+=20basic=5Fauth=20logout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Личный кабинет в правом верхнем углу top-bar (gendsgn.ru + /trade-in/). Компонент: - Avatar 32×32 (первая буква username в круге, `var(--accent-soft)` фон, `var(--accent)` text) - Dropdown по клику: username, role badge («Админ» / «Пилот»), кнопка «Выйти» (text-only, `var(--danger)`) - Закрытие по Escape, click-outside; focus возвращается на avatar - `aria-label="Личный кабинет"`, `role="menu"`, `role="menuitem"` Logout flow (basic_auth quirk): 1. Fetch `/api/v1/me` (через `API_BASE_URL` чтобы single source of truth с `apiFetchWithStatus`) с явно invalid Basic creds `logout:logout` → 401 от Caddy → браузер очищает cached basic_auth для origin 2. `window.location.href = "/"` — hard reload, новый basic_auth prompt Integration: - Main `frontend/`: `` в ``. Pricing CTA (`PilotRequestModal` trigger) на странице остался — не дублировался в TopNav. - Tradein `frontend/`: `` в конце `.top-nav` в `Topbar.tsx` (после tabs). MIRROR с inline LogOut SVG (нет lucide-react dep) + `API_BASE_URL` import для logout fetch (consistency с rest of tradein). Manual smoke план (post-deploy): - Safari incognito → admintest → авitar «A» + role «Админ» - Click «Выйти» → basic_auth prompt появляется снова - Same в Chrome / Firefox (cross-browser logout reliability см. PR review) Code-reviewer LGTM (1 critical fixed inline — tradein logout fetch теперь использует `API_BASE_URL` вместо `process.env.NEXT_PUBLIC_BASE_PATH`). --- frontend/src/app/page.tsx | 23 +- frontend/src/components/auth/UserMenu.tsx | 201 ++++++++++++++++ .../frontend/src/components/auth/UserMenu.tsx | 223 ++++++++++++++++++ .../src/components/trade-in/Topbar.tsx | 4 + 4 files changed, 432 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/auth/UserMenu.tsx create mode 100644 tradein-mvp/frontend/src/components/auth/UserMenu.tsx diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 37e65914..dcf48357 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -12,6 +12,7 @@ import { RefreshCw, } from "lucide-react"; +import { UserMenu } from "@/components/auth/UserMenu"; import { PilotRequestModal } from "@/components/landing/PilotRequestModal"; import { TopNav } from "@/components/landing/TopNav"; import { HeadlineBar } from "@/components/ui/HeadlineBar"; @@ -144,25 +145,9 @@ export default function HomePage() { }} > {/* ── Nav ────────────────────────────────────────────────────────── */} - setModalOpen(true)} - style={{ - padding: "7px 16px", - background: "var(--accent)", - color: "#fff", - border: "none", - borderRadius: 8, - fontSize: 14, - fontWeight: 500, - cursor: "pointer", - }} - > - Запросить пилот - - } - /> + {/* UserMenu — личный кабинет (avatar + dropdown) для залогиненного юзера. + В dev (401) или до загрузки /me — UserMenu сам рендерит null. */} + } /> {/* ── Hero ───────────────────────────────────────────────────────── */} diff --git a/frontend/src/components/auth/UserMenu.tsx b/frontend/src/components/auth/UserMenu.tsx new file mode 100644 index 00000000..2a37dbc3 --- /dev/null +++ b/frontend/src/components/auth/UserMenu.tsx @@ -0,0 +1,201 @@ +"use client"; + +/** + * UserMenu — личный кабинет в правом верхнем углу top-bar (gendsgn.ru/). + * + * Avatar-кнопка с инициалом из username + dropdown: + * - текущий username + * - role badge («Админ» / «Пилот») + * - кнопка «Выйти» — invalidate Caddy basic_auth cached creds в browser + * + * Используется в `TopNav.rightSlot`. Если /me грузится или 401 (dev без + * Caddy) — рендерим null (нечего показывать). + * + * Logout flow — basic_auth quirk: + * 1. Отправляем fetch с заведомо неправильными Basic creds → 401 от Caddy + * → браузер забывает cached creds для этого origin. + * 2. Hard reload (`window.location.href = "/"`) — basic_auth prompt + * покажется заново. + * Это работает в Safari/Chrome/Firefox; legacy `ClearAuthenticationCache` + * не нужен. + */ + +import { useEffect, useRef, useState } from "react"; + +import { LogOut } from "lucide-react"; + +import { useMe } from "@/lib/useMe"; + +/** + * Logout — invalidate basic_auth cache + reload. + * + * Trick: fetch на защищённый endpoint с явно неверным Basic-токеном. + * Caddy ответит 401 (because creds wrong) → браузер выкинет старые cached + * creds для этого origin. Затем hard reload — Caddy снова запросит basic_auth + * prompt, потому что cached creds invalidated. + */ +async function logout(): Promise { + try { + await fetch("/api/v1/me", { + headers: { + Authorization: "Basic " + btoa("logout:logout"), + }, + cache: "no-store", + }); + } catch { + // Сетевая ошибка — всё равно делаем hard reload. + } + // Hard reload форсит новый basic_auth handshake. + window.location.href = "/"; +} + +export function UserMenu() { + const { data, isLoading, error } = useMe(); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const buttonRef = useRef(null); + + // Click outside → закрыть dropdown. + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + // Escape key → закрыть + вернуть focus на avatar. + useEffect(() => { + if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") { + setOpen(false); + buttonRef.current?.focus(); + } + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open]); + + // Loading / 401 / no data — ничего не рендерим. + // TopNav сам обрабатывает 401 (dev без Caddy) — показывает все nav items. + // Тут просто прячемся, чтобы не было «пустого» avatar без username. + if (isLoading || error || !data) return null; + + const initial = data.username[0]?.toUpperCase() ?? "?"; + const roleLabel = data.role === "admin" ? "Админ" : "Пилот"; + + return ( +
+ + + {open && ( +
+
+ {data.username} +
+
+ {roleLabel} +
+
+ +
+ )} +
+ ); +} diff --git a/tradein-mvp/frontend/src/components/auth/UserMenu.tsx b/tradein-mvp/frontend/src/components/auth/UserMenu.tsx new file mode 100644 index 00000000..b57fcf93 --- /dev/null +++ b/tradein-mvp/frontend/src/components/auth/UserMenu.tsx @@ -0,0 +1,223 @@ +"use client"; + +/** + * MIRROR of main frontend `frontend/src/components/auth/UserMenu.tsx` + * — keep in sync manually. Tradein-mvp бандлится отдельно (basePath=/trade-in) + * и НЕ имеет `lucide-react` в deps, поэтому icon заменён на inline SVG. + * + * UserMenu — личный кабинет в правом верхнем углу Topbar (gendsgn.ru/trade-in/). + * + * Avatar-кнопка с инициалом из username + dropdown: + * - текущий username + * - role badge («Админ» / «Пилот») + * - кнопка «Выйти» — invalidate Caddy basic_auth cached creds в browser + * + * Logout flow — basic_auth quirk: + * 1. Отправляем fetch с заведомо неправильными Basic creds → 401 от Caddy + * → браузер забывает cached creds для этого origin. + * 2. Hard reload (`window.location.href = "/"`) — basic_auth prompt + * покажется заново. + */ + +import { useEffect, useRef, useState } from "react"; + +import { API_BASE_URL } from "@/lib/api"; + +import { useMe } from "@/lib/useMe"; + +/** + * Logout — invalidate basic_auth cache + reload. + * + * При basePath=/trade-in мы должны бить именно по защищённому пути + * (`/trade-in/api/v1/me`), иначе Caddy не вытолкнет cached creds для текущего + * scope. После 401 — hard reload на корень сайта (`/`), там basic_auth + * prompt появится заново. + */ +async function logout(): Promise { + // Используем `API_BASE_URL` (тот же который `apiFetchWithStatus` для /me) — + // single source of truth. В проде `API_BASE_URL` = `/trade-in` (через + // `NEXT_PUBLIC_API_BASE_URL` или `NEXT_PUBLIC_BASE_PATH` fallback); в dev "". + try { + await fetch(`${API_BASE_URL}/api/v1/me`, { + headers: { + Authorization: "Basic " + btoa("logout:logout"), + }, + cache: "no-store", + }); + } catch { + // Сетевая ошибка — всё равно делаем hard reload. + } + // Hard reload форсит новый basic_auth handshake. Уходим в корень сайта, + // чтобы prompt не залип в /trade-in scope. + window.location.href = "/"; +} + +/** Inline LogOut icon (lucide-react `LogOut` SVG path, stroke 1.5). */ +function LogOutIcon() { + return ( + + ); +} + +export function UserMenu() { + const { data, isLoading, error } = useMe(); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const buttonRef = useRef(null); + + // Click outside → закрыть dropdown. + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + // Escape key → закрыть + вернуть focus на avatar. + useEffect(() => { + if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") { + setOpen(false); + buttonRef.current?.focus(); + } + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open]); + + // Loading / 401 / no data — ничего не рендерим. + if (isLoading || error || !data) return null; + + const initial = data.username[0]?.toUpperCase() ?? "?"; + const roleLabel = data.role === "admin" ? "Админ" : "Пилот"; + + return ( +
+ + + {open && ( +
+
+ {data.username} +
+
+ {roleLabel} +
+
+ +
+ )} +
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx index f44f1362..63117a55 100644 --- a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx @@ -1,5 +1,6 @@ "use client"; +import { UserMenu } from "@/components/auth/UserMenu"; import { API_BASE_URL, HTTPError } from "@/lib/api"; import { isPathAllowed } from "@/lib/isPathAllowed"; import { useMe } from "@/lib/useMe"; @@ -100,6 +101,9 @@ export function Topbar({ active }: TopbarProps) { {item.label} ))} + {/* UserMenu — личный кабинет справа от nav. В dev (401) или до /me + UserMenu сам рендерит null. */} + -- 2.45.3