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:
@@ -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 {
|
||||
|
||||
@@ -2,39 +2,75 @@ 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) => {
|
||||
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>
|
||||
|
||||
{step.kind === "credentials" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Войдите учётной записью TaxiDashboard.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
m.mutate({ username, password });
|
||||
loginMutation.mutate({ username, password });
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
@@ -61,10 +97,74 @@ export default function Login() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={m.isPending} className="w-full">
|
||||
{m.isPending ? "Входим…" : "Войти"}
|
||||
<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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user