From 7775e7410863570966e93067b09f9e3c95cdd9d5 Mon Sep 17 00:00:00 2001 From: vladtechno Date: Sun, 17 May 2026 11:58:42 +1000 Subject: [PATCH] feat(mechanic-pwa): 2FA flow with trusted-device cookie support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire frontend to backend 2FA contract: two-step state machine in Login.tsx (credentials → totp), new login2fa() call to POST /api/auth/2fa, credentials:include on both fetches so the 30-day trusted_device cookie travels correctly. Co-Authored-By: claude-flow --- mechanic-pwa/frontend/src/api/auth.ts | 30 +++- mechanic-pwa/frontend/src/pages/Login.tsx | 184 +++++++++++++++++----- 2 files changed, 169 insertions(+), 45 deletions(-) diff --git a/mechanic-pwa/frontend/src/api/auth.ts b/mechanic-pwa/frontend/src/api/auth.ts index ca40a76..7cad487 100644 --- a/mechanic-pwa/frontend/src/api/auth.ts +++ b/mechanic-pwa/frontend/src/api/auth.ts @@ -5,13 +5,21 @@ export interface LoginInput { password: string; } +/** OAuth2-shaped login response, extended with backend's 2FA signaling fields. */ export interface LoginOutput { - access_token: string; + access_token?: string | null; token_type: string; + requires_2fa?: boolean; + partial_token?: string | null; +} + +export interface TwoFactorInput { + partial_token: string; + code: string; + remember_device: boolean; } export async function login(input: LoginInput): Promise { - // OAuth2 password flow — form-urlencoded, NOT JSON, NOT under /api/v1/mechanic/ const body = new URLSearchParams(); body.set("username", input.username); body.set("password", input.password); @@ -20,11 +28,27 @@ export async function login(input: LoginInput): Promise { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, + // Critical: include credentials so the 30-day trusted_device cookie is + // sent back to backend on subsequent logins (lets us skip 2FA next time). + credentials: "include", }); if (!res.ok) { throw new Error(`Login failed: ${res.status}`); } - return res.json() as Promise; + return res.json(); +} + +export async function login2fa(input: TwoFactorInput): Promise { + const res = await fetch("/api/auth/2fa", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + credentials: "include", // accept the trusted_device cookie on Set-Cookie + }); + if (!res.ok) { + throw new Error(`2FA failed: ${res.status}`); + } + return res.json(); } export function logout(): void { diff --git a/mechanic-pwa/frontend/src/pages/Login.tsx b/mechanic-pwa/frontend/src/pages/Login.tsx index f684c65..b516449 100644 --- a/mechanic-pwa/frontend/src/pages/Login.tsx +++ b/mechanic-pwa/frontend/src/pages/Login.tsx @@ -2,69 +2,169 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; -import { login } from "@/api/auth"; +import { login, login2fa } from "@/api/auth"; import { useAuth } from "@/store/auth"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +type Step = + | { kind: "credentials" } + | { kind: "totp"; partialToken: string }; + export default function Login() { const navigate = useNavigate(); const setToken = useAuth((s) => s.setToken); + + const [step, setStep] = useState({ kind: "credentials" }); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [rememberDevice, setRememberDevice] = useState(true); - const m = useMutation({ + const loginMutation = useMutation({ mutationFn: login, onSuccess: (data) => { - setToken(data.access_token); - toast.success("Вход выполнен"); - navigate("/", { replace: true }); + if (data.requires_2fa && data.partial_token) { + setStep({ kind: "totp", partialToken: data.partial_token }); + setCode(""); + toast.info("Нужен код 2FA"); + return; + } + if (data.access_token) { + setToken(data.access_token); + toast.success("Вход выполнен"); + navigate("/", { replace: true }); + return; + } + // Defensive: shouldn't happen + toast.error("Неожиданный ответ сервера"); }, onError: () => toast.error("Неверный логин или пароль"), }); + const twoFaMutation = useMutation({ + mutationFn: login2fa, + onSuccess: (data) => { + if (data.access_token) { + setToken(data.access_token); + toast.success("Вход выполнен"); + navigate("/", { replace: true }); + } else { + toast.error("Сервер не вернул токен"); + } + }, + onError: () => toast.error("Неверный код 2FA"), + }); + return (

Premium Механик

-

- Войдите учётной записью TaxiDashboard. -

-
{ - e.preventDefault(); - m.mutate({ username, password }); - }} - className="space-y-3" - > -
- - setUsername(e.target.value)} - required - autoFocus - /> -
-
- - setPassword(e.target.value)} - required - /> -
- -
+ + {step.kind === "credentials" && ( + <> +

+ Войдите учётной записью TaxiDashboard. +

+
{ + e.preventDefault(); + loginMutation.mutate({ username, password }); + }} + className="space-y-3" + > +
+ + setUsername(e.target.value)} + required + autoFocus + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ +
+ + )} + + {step.kind === "totp" && ( + <> +

+ Введите 6-значный код из приложения-аутентификатора (Google + Authenticator, Aegis, 1Password, и т. п.). +

+
{ + e.preventDefault(); + twoFaMutation.mutate({ + partial_token: step.partialToken, + code: code.trim(), + remember_device: rememberDevice, + }); + }} + className="space-y-3" + > +
+ + setCode(e.target.value.replace(/\D/g, "").slice(0, 6))} + required + autoFocus + /> +
+ + + +
+ + )}
);