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
201 lines
6.3 KiB
TypeScript
201 lines
6.3 KiB
TypeScript
"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>
|
||
);
|
||
}
|