Locked/no-access users (403/denied path/expired session/trial ended) were trapped: NoAccessScreen had no logout control, and «Выйти» lived only inside UserMenu, which does not render on the no-access screen. They could not switch accounts without clearing browser basic-auth manually. - Extract logout() into shared `lib/logout.ts` (single source of truth). - UserMenu now imports it instead of a local copy (behaviour unchanged). - NoAccessScreen renders an always-available «Выйти» accent button for every variant. Mirrored across both frontends (tradein-mvp/frontend + main frontend) to keep the MIRROR invariant in sync.
196 lines
5.7 KiB
TypeScript
196 lines
5.7 KiB
TypeScript
"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 { logout } from "@/lib/logout";
|
||
|
||
import { useMe } from "@/lib/useMe";
|
||
|
||
/** 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>
|
||
);
|
||
}
|