feat(tradein/ui): login-форма + session-aware RouteGuard (#2555) #2562

Merged
lekss361 merged 2 commits from feat/tradein-login-ui into main 2026-07-30 18:10:47 +00:00
5 changed files with 363 additions and 8 deletions
Showing only changes of commit 15d506b7dd - Show all commits

View file

@ -0,0 +1,268 @@
"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-парсинге).
*/
function sanitizeNext(next: string | null): string {
if (!next) return "/";
if (!/^\/(?!\/|\\)/.test(next)) return "/";
return next;
}
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>
);
}

View file

@ -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 { logout } from "@/lib/logout"; import { useLogout } from "@/lib/useLogout";
// 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,6 +524,10 @@ 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.
@ -973,7 +977,7 @@ export default function TradeInV2Page() {
onNavigate={setNav} onNavigate={setNav}
reports={reportsCount ?? 0} reports={reportsCount ?? 0}
user={topNavUser} user={topNavUser}
onLogout={logout} onLogout={() => logoutMutation.mutate()}
/> />
</nav> </nav>
<main <main

View file

@ -10,9 +10,17 @@
* 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 { usePathname } from "next/navigation"; import { useRouter, 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";
@ -29,6 +37,7 @@ 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
@ -36,21 +45,44 @@ 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;
router.push(`/login?next=${encodeURIComponent(rawPath)}`);
}, [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}</>;
return <NoAccessScreen variant="session" />; // Prod: редирект уже запущен эффектом выше — ничего не рендерим, пока
// навигация не завершится (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) {

View file

@ -0,0 +1,40 @@
"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");
},
});
}

View file

@ -14,7 +14,18 @@ import { useQuery } from "@tanstack/react-query";
import { apiFetchWithStatus, HTTPError } from "@/lib/api"; import { apiFetchWithStatus, HTTPError } from "@/lib/api";
export type Role = "admin" | "pilot" | "expired"; // #2555: session-auth (POST /api/v1/auth/login) вводит новые роли
// 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;