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;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** OAuth2-shaped login response, extended with backend's 2FA signaling fields. */
|
||||||
export interface LoginOutput {
|
export interface LoginOutput {
|
||||||
access_token: string;
|
access_token?: string | null;
|
||||||
token_type: string;
|
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> {
|
export async function login(input: LoginInput): Promise<LoginOutput> {
|
||||||
// OAuth2 password flow — form-urlencoded, NOT JSON, NOT under /api/v1/mechanic/
|
|
||||||
const body = new URLSearchParams();
|
const body = new URLSearchParams();
|
||||||
body.set("username", input.username);
|
body.set("username", input.username);
|
||||||
body.set("password", input.password);
|
body.set("password", input.password);
|
||||||
@@ -20,11 +28,27 @@ export async function login(input: LoginInput): Promise<LoginOutput> {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
body,
|
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) {
|
if (!res.ok) {
|
||||||
throw new Error(`Login failed: ${res.status}`);
|
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 {
|
export function logout(): void {
|
||||||
|
|||||||
@@ -2,39 +2,75 @@ import { useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { login } from "@/api/auth";
|
import { login, login2fa } from "@/api/auth";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
type Step =
|
||||||
|
| { kind: "credentials" }
|
||||||
|
| { kind: "totp"; partialToken: string };
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const setToken = useAuth((s) => s.setToken);
|
const setToken = useAuth((s) => s.setToken);
|
||||||
|
|
||||||
|
const [step, setStep] = useState<Step>({ kind: "credentials" });
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [rememberDevice, setRememberDevice] = useState(true);
|
||||||
|
|
||||||
const m = useMutation({
|
const loginMutation = useMutation({
|
||||||
mutationFn: login,
|
mutationFn: login,
|
||||||
onSuccess: (data) => {
|
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);
|
setToken(data.access_token);
|
||||||
toast.success("Вход выполнен");
|
toast.success("Вход выполнен");
|
||||||
navigate("/", { replace: true });
|
navigate("/", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Defensive: shouldn't happen
|
||||||
|
toast.error("Неожиданный ответ сервера");
|
||||||
},
|
},
|
||||||
onError: () => 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 (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
<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">
|
<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>
|
<h1 className="text-2xl font-semibold text-foreground">Premium Механик</h1>
|
||||||
|
|
||||||
|
{step.kind === "credentials" && (
|
||||||
|
<>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Войдите учётной записью TaxiDashboard.
|
Войдите учётной записью TaxiDashboard.
|
||||||
</p>
|
</p>
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
m.mutate({ username, password });
|
loginMutation.mutate({ username, password });
|
||||||
}}
|
}}
|
||||||
className="space-y-3"
|
className="space-y-3"
|
||||||
>
|
>
|
||||||
@@ -61,10 +97,74 @@ export default function Login() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={m.isPending} className="w-full">
|
<Button type="submit" disabled={loginMutation.isPending} className="w-full">
|
||||||
{m.isPending ? "Входим…" : "Войти"}
|
{loginMutation.isPending ? "Входим…" : "Войти"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user