gendesign/tradein-mvp/frontend/src/components/auth/UserMenu.tsx
bot-backend a9dfe44f5c
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 51s
CI / openapi-codegen-check (pull_request) Successful in 1m56s
fix(tradein/auth): always-available logout button on NoAccessScreen
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.
2026-06-27 15:16:42 +03:00

196 lines
5.7 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";
/**
* 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>
);
}