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:
parent
d954be1331
commit
358824c284
4 changed files with 432 additions and 19 deletions
|
|
@ -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 ───────────────────────────────────────────────────────── */}
|
||||
|
|
|
|||
201
frontend/src/components/auth/UserMenu.tsx
Normal file
201
frontend/src/components/auth/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
223
tradein-mvp/frontend/src/components/auth/UserMenu.tsx
Normal file
223
tradein-mvp/frontend/src/components/auth/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue