gendesign/frontend/src/components/auth/UserMenu.tsx
Light1YT 56a7a36c9a
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy / changes (push) Successful in 4s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m45s
Deploy Trade-In / deploy (push) Successful in 38s
Deploy / build-frontend (push) Successful in 3m0s
Deploy / deploy (push) Successful in 58s
feat(auth): UserMenu — личный кабинет top-right (PR D) (#590)
2026-05-26 10:48:57 +00:00

201 lines
6.3 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";
/**
* 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>
);
}