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
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).
288 lines
9 KiB
TypeScript
288 lines
9 KiB
TypeScript
"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>
|
||
);
|
||
}
|