diff --git a/tradein-mvp/frontend/src/app/login/page.tsx b/tradein-mvp/frontend/src/app/login/page.tsx new file mode 100644 index 00000000..4d3965dc --- /dev/null +++ b/tradein-mvp/frontend/src/app/login/page.tsx @@ -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 { + 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) { + e.preventDefault(); + if (loginMutation.isPending) return; + loginMutation.mutate({ username: username.trim(), password }); + } + + return ( +
+ + +
+

+ Вход +

+

+ Войдите, чтобы продолжить работу с Мерой. +

+ +
+ + setUsername(e.target.value)} + disabled={loginMutation.isPending} + /> +
+ +
+ + setPassword(e.target.value)} + disabled={loginMutation.isPending} + /> +
+ + {loginMutation.isError ? ( +

+ {loginErrorMessage(loginMutation.error)} +

+ ) : null} + + +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index b4f2a78f..7979ac71 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -66,7 +66,7 @@ import { } from "@/lib/trade-in-api"; import { useQuota } from "@/lib/useQuota"; 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, // 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 / "" // when absent (never invented). undefined while loading → TopNav «Гость». 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 // section via the mappers (null input) rather than blanking the page. @@ -973,7 +977,7 @@ export default function TradeInV2Page() { onNavigate={setNav} reports={reportsCount ?? 0} user={topNavUser} - onLogout={logout} + onLogout={() => logoutMutation.mutate()} />
{ + if (!shouldRedirectToLogin) return; + router.push(`/login?next=${encodeURIComponent(rawPath)}`); + }, [shouldRedirectToLogin, rawPath, router]); + // #801: preview-страница самодостаточна (свой QueryClient с фейковым me), // RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено. if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) { return <>{children}; } + if (isLoginPage) { + return <>{children}; + } + if (isLoading) return null; if (error instanceof HTTPError && error.status === 401) { // 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}; - return ; + // Prod: редирект уже запущен эффектом выше — ничего не рендерим, пока + // навигация не завершится (mounting children on 401 causes TanStack + // Query re-subscribe storm, см. историю до #2555 в git blame). + return null; } if (error instanceof HTTPError && error.status === 403) { diff --git a/tradein-mvp/frontend/src/lib/useLogout.ts b/tradein-mvp/frontend/src/lib/useLogout.ts new file mode 100644 index 00000000..93de98c4 --- /dev/null +++ b/tradein-mvp/frontend/src/lib/useLogout.ts @@ -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 { + 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"); + }, + }); +} diff --git a/tradein-mvp/frontend/src/lib/useMe.ts b/tradein-mvp/frontend/src/lib/useMe.ts index 06ef99e6..21fd14f8 100644 --- a/tradein-mvp/frontend/src/lib/useMe.ts +++ b/tradein-mvp/frontend/src/lib/useMe.ts @@ -14,7 +14,18 @@ import { useQuery } from "@tanstack/react-query"; 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 { username: string;