"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-парсинге). * * 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) { 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}
); }