diff --git a/frontend/src/components/auth/NoAccessScreen.tsx b/frontend/src/components/auth/NoAccessScreen.tsx
index 11c0eb0a..37141e73 100644
--- a/frontend/src/components/auth/NoAccessScreen.tsx
+++ b/frontend/src/components/auth/NoAccessScreen.tsx
@@ -13,6 +13,8 @@
import { Lock } from "lucide-react";
+import { logout } from "@/lib/logout";
+
interface NoAccessScreenProps {
/** "user" — юзер вне ролей; "path" — путь вне scope */
variant: "user" | "path";
@@ -100,6 +102,26 @@ export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
{path}
) : null}
+ {/* Always-available logout — иначе locked/no-access юзер заперт:
+ UserMenu (где обычно «Выйти») на этом экране не рендерится. */}
+
);
diff --git a/frontend/src/components/auth/UserMenu.tsx b/frontend/src/components/auth/UserMenu.tsx
index d30443cb..edb4e864 100644
--- a/frontend/src/components/auth/UserMenu.tsx
+++ b/frontend/src/components/auth/UserMenu.tsx
@@ -24,6 +24,8 @@ import { useEffect, useRef, useState } from "react";
import { LogOut } from "lucide-react";
+import { logout } from "@/lib/logout";
+
import { useMe, type Role } from "@/lib/useMe";
/**
@@ -37,29 +39,6 @@ const ROLE_LABELS: Record = {
analyst: "Аналитик",
};
-/**
- * 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 {
- 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);
diff --git a/frontend/src/lib/logout.ts b/frontend/src/lib/logout.ts
new file mode 100644
index 00000000..4a246c20
--- /dev/null
+++ b/frontend/src/lib/logout.ts
@@ -0,0 +1,25 @@
+/**
+ * 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.
+ *
+ * Это работает в Safari/Chrome/Firefox; legacy `ClearAuthenticationCache`
+ * не нужен.
+ */
+export async function logout(): Promise {
+ 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 = "/";
+}
diff --git a/tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx b/tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx
index e6f0338a..b0f94ffa 100644
--- a/tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx
+++ b/tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx
@@ -9,6 +9,8 @@
* Token-based styling per `.claude/rules/ui-tokens.md` (см. globals.css).
*/
+import { logout } from "@/lib/logout";
+
interface NoAccessScreenProps {
variant: "user" | "path" | "session" | "trial";
path?: string;
@@ -116,6 +118,26 @@ export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
{path}
) : null}
+ {/* Always-available logout — иначе locked/no-access юзер заперт:
+ UserMenu (где обычно «Выйти») на этом экране не рендерится. */}
+
);
diff --git a/tradein-mvp/frontend/src/components/auth/UserMenu.tsx b/tradein-mvp/frontend/src/components/auth/UserMenu.tsx
index b57fcf93..3e7350b5 100644
--- a/tradein-mvp/frontend/src/components/auth/UserMenu.tsx
+++ b/tradein-mvp/frontend/src/components/auth/UserMenu.tsx
@@ -21,37 +21,10 @@
import { useEffect, useRef, useState } from "react";
-import { API_BASE_URL } from "@/lib/api";
+import { logout } from "@/lib/logout";
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 {
- // Используем `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 (
diff --git a/tradein-mvp/frontend/src/lib/logout.ts b/tradein-mvp/frontend/src/lib/logout.ts
new file mode 100644
index 00000000..63b25123
--- /dev/null
+++ b/tradein-mvp/frontend/src/lib/logout.ts
@@ -0,0 +1,34 @@
+import { API_BASE_URL } from "@/lib/api";
+
+/**
+ * Logout — invalidate basic_auth cache + reload.
+ *
+ * Logout flow — basic_auth quirk:
+ * 1. Отправляем fetch с заведомо неправильными Basic creds → 401 от Caddy
+ * → браузер забывает cached creds для этого origin.
+ * 2. Hard reload (`window.location.href = "/"`) — basic_auth prompt
+ * покажется заново.
+ *
+ * При basePath=/trade-in мы должны бить именно по защищённому пути
+ * (`/trade-in/api/v1/me`), иначе Caddy не вытолкнет cached creds для текущего
+ * scope. После 401 — hard reload на корень сайта (`/`), там basic_auth
+ * prompt появится заново.
+ */
+export async function logout(): Promise {
+ // Используем `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 = "/";
+}