Compare commits
No commits in common. "main" and "feat/tradein-auth-core" have entirely different histories.
main
...
feat/trade
5 changed files with 8 additions and 390 deletions
|
|
@ -1,288 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #2555 (эпик #2549) — login-форма для новой DB-backed session auth
|
|
||||||
* (POST /api/v1/auth/login, см. `tradein-mvp/backend/app/api/v1/auth.py`).
|
|
||||||
*
|
|
||||||
* Не гейтится RouteGuard'ом (см. `components/auth/RouteGuard.tsx` —
|
|
||||||
* `isLoginPage` bypass) — иначе редирект-петля: 401 от /me на /login тоже
|
|
||||||
* пытался бы увести на /login.
|
|
||||||
*
|
|
||||||
* `next=` читаем вручную из `window.location.search` (SSR-guard), а НЕ
|
|
||||||
* через `next/navigation` `useSearchParams()` — тот форсит Suspense boundary
|
|
||||||
* и ломает `next build` (см. `app/v2/page.tsx: readUrlId` — тот же паттерн,
|
|
||||||
* уже принятый в этом репо).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import type { CSSProperties, FormEvent } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { apiFetch, HTTPError } from "@/lib/api";
|
|
||||||
import { ME_QUERY_KEY } from "@/lib/useMe";
|
|
||||||
|
|
||||||
interface LoginInput {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loginRequest(input: LoginInput): Promise<void> {
|
|
||||||
await apiFetch<{ ok: boolean }>("/api/v1/auth/login", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function readNextParam(): string | null {
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
return new URLSearchParams(window.location.search).get("next");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open-redirect guard: принимаем только внутренний путь, начинающийся
|
|
||||||
* ровно с одного "/" — не "//host" (protocol-relative URL) и не "/\host"
|
|
||||||
* (браузеры местами трактуют backslash как forward slash в URL-парсинге).
|
|
||||||
*
|
|
||||||
* PR #2562 review finding 1: WHATWG URL-парсер (который `router.push`
|
|
||||||
* использует под капотом) убирает ВСЕ ASCII tab/CR/LF из строки ПЕРЕД
|
|
||||||
* парсингом — так `"/\t//evil"` для наивного regex выглядит как безопасный
|
|
||||||
* путь с одним leading slash (символ в позиции 1 — таб, не "/" и не "\"),
|
|
||||||
* а после навигации превращается в `"//evil"` (protocol-relative → чужой
|
|
||||||
* origin). Убираем те же символы ДО валидации, чтобы regex видел ту же
|
|
||||||
* строку, что увидит парсер.
|
|
||||||
*
|
|
||||||
* PR #2562 review finding 2: `next=/login` (или `/login?...`) после успешного
|
|
||||||
* логина кидал бы юзера обратно на форму входа (RouteGuard не гейтит
|
|
||||||
* `/login`) — dead-end. Фолбэк на "/" в этом случае.
|
|
||||||
*/
|
|
||||||
function sanitizeNext(next: string | null): string {
|
|
||||||
if (!next) return "/";
|
|
||||||
const cleaned = next.replace(/[\t\r\n]/g, "");
|
|
||||||
if (!/^\/(?!\/|\\)/.test(cleaned)) return "/";
|
|
||||||
if (
|
|
||||||
cleaned === "/login" ||
|
|
||||||
cleaned.startsWith("/login?") ||
|
|
||||||
cleaned.startsWith("/login#")
|
|
||||||
) {
|
|
||||||
return "/";
|
|
||||||
}
|
|
||||||
return cleaned;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loginErrorMessage(error: unknown): string {
|
|
||||||
if (error instanceof HTTPError) {
|
|
||||||
if (error.status === 401) return "Неверный логин или пароль";
|
|
||||||
if (error.status === 429) {
|
|
||||||
return "Слишком много попыток. Попробуйте через несколько минут";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "Не удалось войти. Проверьте подключение и попробуйте ещё раз";
|
|
||||||
}
|
|
||||||
|
|
||||||
const cardStyle: CSSProperties = {
|
|
||||||
background: "var(--bg-card)",
|
|
||||||
border: "1px solid var(--border-card)",
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: "32px 28px",
|
|
||||||
maxWidth: 380,
|
|
||||||
width: "100%",
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelStyle: CSSProperties = {
|
|
||||||
display: "block",
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--fg-secondary)",
|
|
||||||
marginBottom: 6,
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputStyle: CSSProperties = {
|
|
||||||
width: "100%",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
height: 40,
|
|
||||||
padding: "0 12px",
|
|
||||||
border: "1px solid var(--border-card)",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 14,
|
|
||||||
color: "var(--fg-primary)",
|
|
||||||
background: "var(--bg-card)",
|
|
||||||
fontFamily: "inherit",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
|
||||||
mutationFn: loginRequest,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
|
|
||||||
router.push(sanitizeNext(readNextParam()));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (loginMutation.isPending) return;
|
|
||||||
loginMutation.mutate({ username: username.trim(), password });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
style={{
|
|
||||||
minHeight: "100vh",
|
|
||||||
background: "var(--bg-app)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: 24,
|
|
||||||
fontFamily: "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<style>{`
|
|
||||||
@keyframes login-spin { to { transform: rotate(360deg); } }
|
|
||||||
.login-spinner { animation: login-spin .7s linear infinite; }
|
|
||||||
.login-input:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
|
||||||
}
|
|
||||||
.login-submit:hover:not(:disabled) { background: var(--accent-hover); }
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} style={cardStyle} aria-label="Вход в Меру">
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
margin: "0 0 8px",
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--fg-primary)",
|
|
||||||
lineHeight: 1.25,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Вход
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
margin: "0 0 24px",
|
|
||||||
fontSize: 14,
|
|
||||||
color: "var(--fg-secondary)",
|
|
||||||
lineHeight: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Войдите, чтобы продолжить работу с Мерой.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<label style={labelStyle} htmlFor="login-username">
|
|
||||||
Логин
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="login-username"
|
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
autoComplete="username"
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
className="login-input"
|
|
||||||
style={inputStyle}
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
disabled={loginMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: 24 }}>
|
|
||||||
<label style={labelStyle} htmlFor="login-password">
|
|
||||||
Пароль
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="login-password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
className="login-input"
|
|
||||||
style={inputStyle}
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
disabled={loginMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loginMutation.isError ? (
|
|
||||||
<p
|
|
||||||
role="alert"
|
|
||||||
style={{
|
|
||||||
margin: "0 0 16px",
|
|
||||||
padding: "8px 12px",
|
|
||||||
borderRadius: 8,
|
|
||||||
background: "var(--danger-soft)",
|
|
||||||
color: "var(--danger)",
|
|
||||||
fontSize: 13,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loginErrorMessage(loginMutation.error)}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="login-submit"
|
|
||||||
disabled={loginMutation.isPending}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 8,
|
|
||||||
background: "var(--accent)",
|
|
||||||
color: "#FFFFFF",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: "10px 16px",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: loginMutation.isPending ? "wait" : "pointer",
|
|
||||||
opacity: loginMutation.isPending ? 0.75 : 1,
|
|
||||||
fontFamily: "inherit",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loginMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="login-spinner"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="9"
|
|
||||||
stroke="rgba(255,255,255,0.35)"
|
|
||||||
strokeWidth="3"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M21 12a9 9 0 0 0-9-9"
|
|
||||||
stroke="#FFFFFF"
|
|
||||||
strokeWidth="3"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Входим…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Войти"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -66,7 +66,7 @@ import {
|
||||||
} from "@/lib/trade-in-api";
|
} from "@/lib/trade-in-api";
|
||||||
import { useQuota } from "@/lib/useQuota";
|
import { useQuota } from "@/lib/useQuota";
|
||||||
import { useMe } from "@/lib/useMe";
|
import { useMe } from "@/lib/useMe";
|
||||||
import { useLogout } from "@/lib/useLogout";
|
import { logout } from "@/lib/logout";
|
||||||
|
|
||||||
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
|
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
|
||||||
// non-interactive overlay drawn over the artboard gradient. The frame has
|
// non-interactive overlay drawn over the artboard gradient. The frame has
|
||||||
|
|
@ -524,10 +524,6 @@ export default function TradeInV2Page() {
|
||||||
// (#2046) — known profile fields fall back to username / brand ?? role / ""
|
// (#2046) — known profile fields fall back to username / brand ?? role / ""
|
||||||
// when absent (never invented). undefined while loading → TopNav «Гость».
|
// when absent (never invented). undefined while loading → TopNav «Гость».
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
// #2555: session-logout (POST /api/v1/auth/logout + local /me-cache
|
|
||||||
// invalidate + redirect на /login) — replaces the legacy basic_auth-cache
|
|
||||||
// -bust `logout()` for the v2 TopNav (new /login-form users).
|
|
||||||
const logoutMutation = useLogout();
|
|
||||||
|
|
||||||
// Dashboard sub-hooks — each resolves independently; failure degrades its
|
// Dashboard sub-hooks — each resolves independently; failure degrades its
|
||||||
// section via the mappers (null input) rather than blanking the page.
|
// section via the mappers (null input) rather than blanking the page.
|
||||||
|
|
@ -977,7 +973,7 @@ export default function TradeInV2Page() {
|
||||||
onNavigate={setNav}
|
onNavigate={setNav}
|
||||||
reports={reportsCount ?? 0}
|
reports={reportsCount ?? 0}
|
||||||
user={topNavUser}
|
user={topNavUser}
|
||||||
onLogout={() => logoutMutation.mutate()}
|
onLogout={logout}
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
<main
|
<main
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,9 @@
|
||||||
* RBAC config (`auth/roles.yaml`) использует абсолютные пути сайта
|
* RBAC config (`auth/roles.yaml`) использует абсолютные пути сайта
|
||||||
* (`/trade-in/**`, `/trade-in/api/v1/admin/**`), поэтому перед проверкой
|
* (`/trade-in/**`, `/trade-in/api/v1/admin/**`), поэтому перед проверкой
|
||||||
* isPathAllowed мы префиксим pathname через NEXT_PUBLIC_BASE_PATH.
|
* isPathAllowed мы префиксим pathname через NEXT_PUBLIC_BASE_PATH.
|
||||||
*
|
|
||||||
* #2555 login redirect: `router.push()` (как и `usePathname()`) работает в
|
|
||||||
* пространстве путей БЕЗ basePath — Next сам префиксит basePath на навигации
|
|
||||||
* (см. `next.config.ts` комментарий `basePath`). Поэтому `next=` в query
|
|
||||||
* строится из `rawPath` (БЕЗ basePath), а не `absolutePath` — иначе
|
|
||||||
* `/login/page.tsx` сделал бы `router.push("/trade-in/history")`, и Next
|
|
||||||
* задвоил бы префикс в `/trade-in/trade-in/history`.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
|
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
|
||||||
import { HTTPError } from "@/lib/api";
|
import { HTTPError } from "@/lib/api";
|
||||||
|
|
@ -37,7 +29,6 @@ interface RouteGuardProps {
|
||||||
|
|
||||||
export function RouteGuard({ children }: RouteGuardProps) {
|
export function RouteGuard({ children }: RouteGuardProps) {
|
||||||
const rawPath = usePathname() ?? "/";
|
const rawPath = usePathname() ?? "/";
|
||||||
const router = useRouter();
|
|
||||||
// Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем
|
// Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем
|
||||||
// на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок).
|
// на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок).
|
||||||
const absolutePath = BASE_PATH
|
const absolutePath = BASE_PATH
|
||||||
|
|
@ -45,51 +36,21 @@ export function RouteGuard({ children }: RouteGuardProps) {
|
||||||
: rawPath;
|
: rawPath;
|
||||||
const { data, isLoading, error } = useMe();
|
const { data, isLoading, error } = useMe();
|
||||||
|
|
||||||
// #2555: /login сам себя не гейтит — иначе редирект-петля (401 на /me →
|
|
||||||
// редирект на /login → RouteGuard на /login опять видит 401 → редирект…).
|
|
||||||
const isLoginPage = rawPath === "/login";
|
|
||||||
|
|
||||||
// Prod-only: сессия истекла/отсутствует → уводим на логин вместо старого
|
|
||||||
// NoAccessScreen variant="session". Редирект — побочный эффект (нельзя
|
|
||||||
// router.push во время рендера), поэтому useEffect; пока он не сработал,
|
|
||||||
// рендерим null (см. return ниже), чтобы не мигал старый contents.
|
|
||||||
const shouldRedirectToLogin =
|
|
||||||
!isLoginPage &&
|
|
||||||
process.env.NODE_ENV === "production" &&
|
|
||||||
error instanceof HTTPError &&
|
|
||||||
error.status === 401;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldRedirectToLogin) return;
|
|
||||||
// PR #2562 review finding 3: deep-links carry их state в query (`/v2?id=
|
|
||||||
// <uuid>` — см. next.config.ts redirect comment про restore-by-id). Без
|
|
||||||
// `window.location.search` юзер, чья сессия истекла mid-session на такой
|
|
||||||
// ссылке, после логина попадал бы на голый `/v2` и терял отчёт. Effect
|
|
||||||
// — гарантированно client-side (useEffect тело никогда не бежит на SSR),
|
|
||||||
// поэтому `window` тут безопасен без typeof-guard.
|
|
||||||
const next = `${rawPath}${window.location.search}`;
|
|
||||||
router.push(`/login?next=${encodeURIComponent(next)}`);
|
|
||||||
}, [shouldRedirectToLogin, rawPath, router]);
|
|
||||||
|
|
||||||
// #801: preview-страница самодостаточна (свой QueryClient с фейковым me),
|
// #801: preview-страница самодостаточна (свой QueryClient с фейковым me),
|
||||||
// RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено.
|
// RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено.
|
||||||
if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) {
|
if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoginPage) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
|
|
||||||
if (error instanceof HTTPError && error.status === 401) {
|
if (error instanceof HTTPError && error.status === 401) {
|
||||||
// Dev without Caddy: 401 is normal, mount the app so local dev works.
|
// Dev without Caddy: 401 is normal, mount the app so local dev works.
|
||||||
|
// Prod: mounting children on 401 causes TanStack Query re-subscribe storm
|
||||||
|
// (each new observer on errored query triggers a refetch). Show session screen
|
||||||
|
// instead — prevents the subtree from mounting, kills the loop.
|
||||||
if (process.env.NODE_ENV !== "production") return <>{children}</>;
|
if (process.env.NODE_ENV !== "production") return <>{children}</>;
|
||||||
// Prod: редирект уже запущен эффектом выше — ничего не рендерим, пока
|
return <NoAccessScreen variant="session" />;
|
||||||
// навигация не завершится (mounting children on 401 causes TanStack
|
|
||||||
// Query re-subscribe storm, см. историю до #2555 в git blame).
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof HTTPError && error.status === 403) {
|
if (error instanceof HTTPError && error.status === 403) {
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #2555: session-logout — POST /api/v1/auth/logout (revoke DB session +
|
|
||||||
* очистка httponly cookie tradein_session), затем чистим локальный
|
|
||||||
* TanStack Query /me-кэш и уходим на /login.
|
|
||||||
*
|
|
||||||
* NB: это НЕ замена legacy `@/lib/logout.ts` (Caddy basic_auth cache-bust +
|
|
||||||
* hard reload) — тот остаётся для страниц/пользователей на старом
|
|
||||||
* trusted-header механизме (см. `app.core.rbac` dual-mode resolver).
|
|
||||||
* useLogout — для юзеров, залогиненных через новую /login форму (#2552).
|
|
||||||
*
|
|
||||||
* Backend logout — best-effort по духу (revoke конкретной сессии), поэтому
|
|
||||||
* локальный logout (кэш + редирект) выполняется в `onSettled`, а не только
|
|
||||||
* `onSuccess`: сетевой сбой / уже-протухшая сессия не должны запирать юзера
|
|
||||||
* на странице без возможности разлогиниться.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
|
||||||
import { ME_QUERY_KEY } from "@/lib/useMe";
|
|
||||||
|
|
||||||
async function logoutRequest(): Promise<void> {
|
|
||||||
await apiFetch<{ ok: boolean }>("/api/v1/auth/logout", { method: "POST" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLogout() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: logoutRequest,
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
|
|
||||||
router.push("/login");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -14,18 +14,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiFetchWithStatus, HTTPError } from "@/lib/api";
|
import { apiFetchWithStatus, HTTPError } from "@/lib/api";
|
||||||
|
|
||||||
// #2555: session-auth (POST /api/v1/auth/login) вводит новые роли
|
export type Role = "admin" | "pilot" | "expired";
|
||||||
// admin|manager|employee. Legacy Caddy trusted-header роли (pilot|analyst|
|
|
||||||
// expired) остаются — backend `/api/v1/me` может отдать любую из обеих
|
|
||||||
// групп в зависимости от того, каким механизмом пришёл юзер (dual-mode
|
|
||||||
// resolver, см. `tradein-mvp/backend/app/core/rbac.py`).
|
|
||||||
export type Role =
|
|
||||||
| "admin"
|
|
||||||
| "manager"
|
|
||||||
| "employee"
|
|
||||||
| "pilot"
|
|
||||||
| "analyst"
|
|
||||||
| "expired";
|
|
||||||
|
|
||||||
export interface UserScope {
|
export interface UserScope {
|
||||||
username: string;
|
username: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue