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.
This commit is contained in:
parent
c1e0ccb2e8
commit
a9dfe44f5c
6 changed files with 106 additions and 51 deletions
|
|
@ -13,6 +13,8 @@
|
||||||
|
|
||||||
import { Lock } from "lucide-react";
|
import { Lock } from "lucide-react";
|
||||||
|
|
||||||
|
import { logout } from "@/lib/logout";
|
||||||
|
|
||||||
interface NoAccessScreenProps {
|
interface NoAccessScreenProps {
|
||||||
/** "user" — юзер вне ролей; "path" — путь вне scope */
|
/** "user" — юзер вне ролей; "path" — путь вне scope */
|
||||||
variant: "user" | "path";
|
variant: "user" | "path";
|
||||||
|
|
@ -100,6 +102,26 @@ export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
|
||||||
{path}
|
{path}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { LogOut } from "lucide-react";
|
import { LogOut } from "lucide-react";
|
||||||
|
|
||||||
|
import { logout } from "@/lib/logout";
|
||||||
|
|
||||||
import { useMe, type Role } from "@/lib/useMe";
|
import { useMe, type Role } from "@/lib/useMe";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -37,29 +39,6 @@ const ROLE_LABELS: Record<Role, string> = {
|
||||||
analyst: "Аналитик",
|
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() {
|
export function UserMenu() {
|
||||||
const { data, isLoading, error } = useMe();
|
const { data, isLoading, error } = useMe();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
|
||||||
25
frontend/src/lib/logout.ts
Normal file
25
frontend/src/lib/logout.ts
Normal 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 = "/";
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
* Token-based styling per `.claude/rules/ui-tokens.md` (см. globals.css).
|
* Token-based styling per `.claude/rules/ui-tokens.md` (см. globals.css).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { logout } from "@/lib/logout";
|
||||||
|
|
||||||
interface NoAccessScreenProps {
|
interface NoAccessScreenProps {
|
||||||
variant: "user" | "path" | "session" | "trial";
|
variant: "user" | "path" | "session" | "trial";
|
||||||
path?: string;
|
path?: string;
|
||||||
|
|
@ -116,6 +118,26 @@ export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
|
||||||
{path}
|
{path}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -21,37 +21,10 @@
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { API_BASE_URL } from "@/lib/api";
|
import { logout } from "@/lib/logout";
|
||||||
|
|
||||||
import { useMe } from "@/lib/useMe";
|
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). */
|
/** Inline LogOut icon (lucide-react `LogOut` SVG path, stroke 1.5). */
|
||||||
function LogOutIcon() {
|
function LogOutIcon() {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
34
tradein-mvp/frontend/src/lib/logout.ts
Normal file
34
tradein-mvp/frontend/src/lib/logout.ts
Normal 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 = "/";
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue