feat(mechanic-pwa): 2FA flow with trusted-device cookie support

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 <ruv@ruv.net>
This commit is contained in:
2026-05-17 11:58:42 +10:00
co-authored by claude-flow
parent da0436cee8
commit 7775e74108
2 changed files with 169 additions and 45 deletions
+27 -3
View File
@@ -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<LoginOutput> {
// 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<LoginOutput> {
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<LoginOutput>;
return res.json();
}
export async function login2fa(input: TwoFactorInput): Promise<LoginOutput> {
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 {
+142 -42
View File
@@ -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<Step>({ 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 (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-sm bg-card rounded-2xl shadow-sm p-6 space-y-4">
<h1 className="text-2xl font-semibold text-foreground">Premium Механик</h1>
<p className="text-sm text-muted-foreground">
Войдите учётной записью TaxiDashboard.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
m.mutate({ username, password });
}}
className="space-y-3"
>
<div className="space-y-1">
<Label htmlFor="username">Логин</Label>
<Input
id="username"
type="text"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
/>
</div>
<div className="space-y-1">
<Label htmlFor="password">Пароль</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" disabled={m.isPending} className="w-full">
{m.isPending ? "Входим…" : "Войти"}
</Button>
</form>
{step.kind === "credentials" && (
<>
<p className="text-sm text-muted-foreground">
Войдите учётной записью TaxiDashboard.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
loginMutation.mutate({ username, password });
}}
className="space-y-3"
>
<div className="space-y-1">
<Label htmlFor="username">Логин</Label>
<Input
id="username"
type="text"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
/>
</div>
<div className="space-y-1">
<Label htmlFor="password">Пароль</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" disabled={loginMutation.isPending} className="w-full">
{loginMutation.isPending ? "Входим…" : "Войти"}
</Button>
</form>
</>
)}
{step.kind === "totp" && (
<>
<p className="text-sm text-muted-foreground">
Введите 6-значный код из приложения-аутентификатора (Google
Authenticator, Aegis, 1Password, и т. п.).
</p>
<form
onSubmit={(e) => {
e.preventDefault();
twoFaMutation.mutate({
partial_token: step.partialToken,
code: code.trim(),
remember_device: rememberDevice,
});
}}
className="space-y-3"
>
<div className="space-y-1">
<Label htmlFor="totp">Код 2FA</Label>
<Input
id="totp"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]{6}"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
required
autoFocus
/>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
<input
type="checkbox"
checked={rememberDevice}
onChange={(e) => setRememberDevice(e.target.checked)}
/>
Запомнить это устройство на 30 дней
</label>
<Button
type="submit"
disabled={twoFaMutation.isPending || code.length !== 6}
className="w-full"
>
{twoFaMutation.isPending ? "Проверяем…" : "Войти"}
</Button>
<button
type="button"
onClick={() => {
setStep({ kind: "credentials" });
setPassword("");
setCode("");
}}
className="w-full text-xs text-muted-foreground hover:text-foreground"
>
Назад
</button>
</form>
</>
)}
</div>
</div>
);