feat(auth): UserMenu — личный кабинет top-right (PR D) #590
4 changed files with 432 additions and 19 deletions
|
|
@ -12,6 +12,7 @@ import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { UserMenu } from "@/components/auth/UserMenu";
|
||||||
import { PilotRequestModal } from "@/components/landing/PilotRequestModal";
|
import { PilotRequestModal } from "@/components/landing/PilotRequestModal";
|
||||||
import { TopNav } from "@/components/landing/TopNav";
|
import { TopNav } from "@/components/landing/TopNav";
|
||||||
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
||||||
|
|
@ -144,25 +145,9 @@ export default function HomePage() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Nav ────────────────────────────────────────────────────────── */}
|
{/* ── Nav ────────────────────────────────────────────────────────── */}
|
||||||
<TopNav
|
{/* UserMenu — личный кабинет (avatar + dropdown) для залогиненного юзера.
|
||||||
rightSlot={
|
В dev (401) или до загрузки /me — UserMenu сам рендерит null. */}
|
||||||
<button
|
<TopNav rightSlot={<UserMenu />} />
|
||||||
onClick={() => setModalOpen(true)}
|
|
||||||
style={{
|
|
||||||
padding: "7px 16px",
|
|
||||||
background: "var(--accent)",
|
|
||||||
color: "#fff",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Запросить пилот
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
|
||||||
{/* ── Hero ───────────────────────────────────────────────────────── */}
|
{/* ── 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";
|
"use client";
|
||||||
|
|
||||||
|
import { UserMenu } from "@/components/auth/UserMenu";
|
||||||
import { API_BASE_URL, HTTPError } from "@/lib/api";
|
import { API_BASE_URL, HTTPError } from "@/lib/api";
|
||||||
import { isPathAllowed } from "@/lib/isPathAllowed";
|
import { isPathAllowed } from "@/lib/isPathAllowed";
|
||||||
import { useMe } from "@/lib/useMe";
|
import { useMe } from "@/lib/useMe";
|
||||||
|
|
@ -100,6 +101,9 @@ export function Topbar({ active }: TopbarProps) {
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
{/* UserMenu — личный кабинет справа от nav. В dev (401) или до /me
|
||||||
|
UserMenu сам рендерит null. */}
|
||||||
|
<UserMenu />
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue