feat(auth): UserMenu — avatar + dropdown + basic_auth logout

Личный кабинет в правом верхнем углу 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/`: `<UserMenu />` в `<TopNav rightSlot>`. Pricing CTA
  (`PilotRequestModal` trigger) на странице остался — не дублировался
  в TopNav.
- Tradein `frontend/`: `<UserMenu />` в конце `.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`).
This commit is contained in:
Light1YT 2026-05-26 15:48:26 +05:00
parent d954be1331
commit 358824c284
4 changed files with 432 additions and 19 deletions

View file

@ -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 ────────────────────────────────────────────────────────── */}
<TopNav
rightSlot={
<button
onClick={() => setModalOpen(true)}
style={{
padding: "7px 16px",
background: "var(--accent)",
color: "#fff",
border: "none",
borderRadius: 8,
fontSize: 14,
fontWeight: 500,
cursor: "pointer",
}}
>
Запросить пилот
</button>
}
/>
{/* UserMenu личный кабинет (avatar + dropdown) для залогиненного юзера.
В dev (401) или до загрузки /me UserMenu сам рендерит null. */}
<TopNav rightSlot={<UserMenu />} />
{/* ── Hero ───────────────────────────────────────────────────────── */}

View file

@ -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<void> {
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<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(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 (
<div ref={containerRef} style={{ position: "relative" }}>
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-label="Личный кабинет"
aria-haspopup="menu"
aria-expanded={open}
style={{
width: 32,
height: 32,
borderRadius: "50%",
background: "var(--accent-soft)",
color: "var(--accent)",
border: "none",
fontWeight: 600,
fontSize: 14,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
fontFamily: "inherit",
}}
>
{initial}
</button>
{open && (
<div
role="menu"
aria-label="Личный кабинет"
style={{
position: "absolute",
top: "calc(100% + 8px)",
right: 0,
minWidth: 200,
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 8,
padding: 12,
// Popover (не card) — допустимая лёгкая тень для отделения.
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)",
zIndex: 200,
}}
>
<div
style={{
fontSize: 14,
fontWeight: 500,
color: "var(--fg-primary)",
marginBottom: 4,
wordBreak: "break-all",
}}
>
{data.username}
</div>
<div
style={{
display: "inline-block",
background: "var(--accent-soft)",
color: "var(--accent)",
padding: "2px 8px",
borderRadius: 4,
fontSize: 11,
textTransform: "uppercase",
letterSpacing: "0.04em",
fontWeight: 500,
}}
>
{roleLabel}
</div>
<hr
style={{
margin: "12px 0",
border: "none",
borderTop: "1px solid var(--border-soft)",
}}
/>
<button
role="menuitem"
type="button"
onClick={logout}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
background: "none",
border: "none",
padding: "4px 0",
cursor: "pointer",
color: "var(--danger)",
fontWeight: 500,
fontSize: 14,
textAlign: "left",
fontFamily: "inherit",
}}
>
<LogOut size={16} strokeWidth={1.5} />
Выйти
</button>
</div>
)}
</div>
);
}

View file

@ -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<void> {
// Используем `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 (
<svg
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
);
}
export function UserMenu() {
const { data, isLoading, error } = useMe();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(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 (
<div ref={containerRef} style={{ position: "relative" }}>
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-label="Личный кабинет"
aria-haspopup="menu"
aria-expanded={open}
style={{
width: 32,
height: 32,
borderRadius: "50%",
background: "var(--accent-soft)",
color: "var(--accent)",
border: "none",
fontWeight: 600,
fontSize: 14,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
fontFamily: "inherit",
}}
>
{initial}
</button>
{open && (
<div
role="menu"
aria-label="Личный кабинет"
style={{
position: "absolute",
top: "calc(100% + 8px)",
right: 0,
minWidth: 200,
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 8,
padding: 12,
// Popover (не card) — допустимая лёгкая тень для отделения.
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)",
zIndex: 200,
}}
>
<div
style={{
fontSize: 14,
fontWeight: 500,
color: "var(--fg-primary)",
marginBottom: 4,
wordBreak: "break-all",
}}
>
{data.username}
</div>
<div
style={{
display: "inline-block",
background: "var(--accent-soft)",
color: "var(--accent)",
padding: "2px 8px",
borderRadius: 4,
fontSize: 11,
textTransform: "uppercase",
letterSpacing: "0.04em",
fontWeight: 500,
}}
>
{roleLabel}
</div>
<hr
style={{
margin: "12px 0",
border: "none",
borderTop: "1px solid var(--border-soft)",
}}
/>
<button
role="menuitem"
type="button"
onClick={logout}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
background: "none",
border: "none",
padding: "4px 0",
cursor: "pointer",
color: "var(--danger)",
fontWeight: 500,
fontSize: 14,
textAlign: "left",
fontFamily: "inherit",
}}
>
<LogOutIcon />
Выйти
</button>
</div>
)}
</div>
);
}

View file

@ -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}
</a>
))}
{/* UserMenu личный кабинет справа от nav. В dev (401) или до /me
UserMenu сам рендерит null. */}
<UserMenu />
</nav>
</div>
</header>