Merge pull request 'fix(tradein/auth): always-available logout button on NoAccessScreen' (#1986) from fix/tradein-logout-on-noaccess into main
Some checks failed
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m22s
Deploy Trade-In / deploy (push) Successful in 55s
Deploy / build-frontend (push) Successful in 3m50s
Deploy / deploy (push) Has been cancelled

This commit is contained in:
lekss361 2026-06-27 12:18:34 +00:00
commit 727f9b1f05
6 changed files with 106 additions and 51 deletions

View file

@ -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}
</p>
) : null}
{/* Always-available logout иначе locked/no-access юзер заперт:
UserMenu (где обычно «Выйти») на этом экране не рендерится. */}
<button
type="button"
onClick={() => logout()}
style={{
marginTop: 20,
background: "var(--accent)",
color: "#FFFFFF",
border: "none",
borderRadius: 8,
padding: "10px 16px",
fontSize: 14,
fontWeight: 500,
cursor: "pointer",
fontFamily: "inherit",
}}
>
Выйти
</button>
</div>
</main>
);

View file

@ -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<Role, string> = {
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<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);

View file

@ -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<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 = "/";
}

View file

@ -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}
</p>
) : null}
{/* Always-available logout иначе locked/no-access юзер заперт:
UserMenu (где обычно «Выйти») на этом экране не рендерится. */}
<button
type="button"
onClick={() => logout()}
style={{
marginTop: 20,
background: "var(--accent)",
color: "#FFFFFF",
border: "none",
borderRadius: 8,
padding: "10px 16px",
fontSize: 14,
fontWeight: 500,
cursor: "pointer",
fontFamily: "inherit",
}}
>
Выйти
</button>
</div>
</main>
);

View file

@ -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<void> {
// Используем `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 (

View file

@ -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<void> {
// Используем `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 = "/";
}