gendesign/tradein-mvp/frontend/src/app/login/page.tsx
bot-backend e6d68c349e
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m1s
fix(tradein/ui): харденинг sanitizeNext + сохранение query в next (#2555)
PR #2562 review, 3 однострочника:
1. sanitizeNext обходился: WHATWG URL-парсер (router.push) вырезает ASCII
   tab/CR/LF из ВСЕЙ строки перед парсингом, так что "/\t//evil" проходил
   regex (позиция 1 — таб, не "/"/"\\"), а после навигации резолвился в
   protocol-relative "//evil" → чужой origin. Теперь сначала strip
   [\t\r\n], потом валидация — regex видит ту же строку, что увидит парсер.
2. next=/login (или /login?...) кидал юзера обратно на форму входа
   (RouteGuard не гейтит /login) — dead-end. Фолбэк на "/".
3. RouteGuard брал next= только из usePathname(), без query — сессия,
   истёкшая на deep-link (/v2?id=<uuid>), теряла отчёт после релогина.
   Добавлен window.location.search в next (effect всегда client-side).
2026-07-30 20:57:54 +03:00

288 lines
9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}