This lesson wires the login and registration forms from Chapter 38 to the actual FastAPI endpoints, connecting the complete path from form submission through the Zustand auth store to the FastAPI JWT issuance. The key integration points are: the Zustand store’s login action that calls the API and persists tokens, the React router redirect to the intended destination after login, and the immediate header update that reflects the newly logged-in user without any additional fetches.
Zustand Auth Store — Full Implementation
// src/stores/authStore.js
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { authApi } from "@/services/auth";
export const useAuthStore = create(
persist(
(set, get) => ({
// ── State ─────────────────────────────────────────────────────────
user: null,
accessToken: null,
isLoading: true, // true until init() validates stored token
// ── Init: validate stored token on app startup ─────────────────────
init: async () => {
const token = get().accessToken; // persisted by Zustand
if (!token) { set({ isLoading: false }); return; }
try {
const user = await authApi.me();
set({ user, isLoading: false });
} catch {
set({ user: null, accessToken: null, isLoading: false });
}
},
// ── Login ──────────────────────────────────────────────────────────
login: async (email, password) => {
const { access_token, refresh_token } = await authApi.login(email, password);
// persist middleware saves accessToken to localStorage automatically
set({ accessToken: access_token });
localStorage.setItem("refresh_token", refresh_token);
const user = await authApi.me();
set({ user });
return user;
},
// ── Register (creates account + auto-login) ────────────────────────
register: async (data) => {
await authApi.register(data);
return get().login(data.email, data.password);
},
// ── Logout ─────────────────────────────────────────────────────────
logout: async () => {
const refreshToken = localStorage.getItem("refresh_token");
try { await authApi.logout(refreshToken); } catch {}
localStorage.removeItem("refresh_token");
set({ user: null, accessToken: null });
},
// ── Token update (called by RTK Query reauth wrapper) ──────────────
setAccessToken: (token) => set({ accessToken: token }),
}),
{
name: "auth-store",
partialize: (s) => ({ accessToken: s.accessToken }),
// Only persist accessToken — not user object (refetched) or isLoading
}
)
);
Note: The Zustand
persist middleware stores the accessToken in localStorage under the key "auth-store" (configurable via the name option). On the next page load, Zustand reads this value back and the init() action validates it. The user object itself is not persisted — it is refetched from /api/users/me on every startup to ensure freshness. Persisting the user object would risk showing stale data (old name, old role) if the server data changed while the tab was closed.Tip: Call
init() in a useEffect near the top of the component tree — ideally in App.jsx — so the token validation completes before any protected routes try to render: const init = useAuthStore(s => s.init); useEffect(() => { init(); }, [init]);. The isLoading state will be true during this validation, which the ProtectedRoute component uses to show a spinner instead of prematurely redirecting to login.Warning: The
login action in the Zustand store makes two API calls: POST /auth/login (to get tokens) and GET /users/me (to get user data). If the second call fails (unlikely but possible with a network blip), the access token is stored but user remains null — the user appears unauthenticated. Add a catch that sets the user from the token payload as a fallback, or retry the /users/me call with exponential backoff.Login Page — Full Integration
// src/pages/LoginPage.jsx
import { useState } from "react";
import { Link, useNavigate, useLocation } from "react-router-dom";
import { useAuthStore } from "@/stores/authStore";
import { useToast } from "@/context/ToastContext";
export default function LoginPage() {
const [form, setForm] = useState({ email: "", password: "" });
const [error, setError] = useState(null);
const [isLoading, setLoading] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const login = useAuthStore((s) => s.login);
const toast = useToast();
const from = location.state?.from?.pathname ?? "/dashboard";
function handleChange(e) {
setForm((p) => ({ ...p, [e.target.name]: e.target.value }));
setError(null);
}
async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
try {
await login(form.email.trim().toLowerCase(), form.password);
toast.success("Welcome back!");
navigate(from, { replace: true });
} catch (err) {
if (err.response?.status === 401) {
setError("Invalid email or password");
} else {
setError("Login failed — please try again");
}
} finally {
setLoading(false);
}
}
return (
<div className="max-w-sm mx-auto mt-16">
<h1 className="text-2xl font-bold mb-6">Sign In</h1>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700
px-4 py-3 rounded mb-4 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">Email</label>
<input id="email" name="email" type="email" autoComplete="email"
value={form.email} onChange={handleChange}
className="w-full border rounded-lg px-3 py-2" />
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">Password</label>
<input id="password" name="password" type="password"
autoComplete="current-password"
value={form.password} onChange={handleChange}
className="w-full border rounded-lg px-3 py-2" />
</div>
<button type="submit" disabled={isLoading}
className="w-full bg-blue-600 text-white py-2 rounded-lg
font-medium disabled:opacity-50">
{isLoading ? "Signing in…" : "Sign In"}
</button>
</form>
<p className="text-center text-sm text-gray-500 mt-4">
No account?{" "}
<Link to="/register" className="text-blue-600 hover:underline">Sign up</Link>
</p>
</div>
);
}
Common Mistakes
Mistake 1 — Not awaiting login before navigating
❌ Wrong — navigates before tokens are stored:
login(email, password); // fire and forget
navigate("/dashboard"); // user and tokens not ready yet!
✅ Correct — await login, then navigate:
await login(email, password); // ✓ tokens stored, user populated
navigate(from, { replace: true });
Mistake 2 — Using specific error messages for wrong password
❌ Wrong — reveals which emails are registered (user enumeration).
✅ Correct — always generic: “Invalid email or password”.