Authentication state flows into every layer of the UI — which buttons appear, which routes are accessible, and which data is shown. A post’s edit and delete buttons should only appear for its author; the comment form should only appear for logged-in users; admin controls should only appear for admins. This “ownership-aware rendering” must never rely solely on hiding buttons — the backend still validates ownership on every mutation — but the UI should match the user’s actual permissions to avoid confusing interactions.
Ownership-Aware Components
import { useAuthStore } from "@/stores/authStore";
import { useDeletePostMutation, useUpdatePostMutation } from "@/store/apiSlice";
import { useNavigate } from "react-router-dom";
import { useToast } from "@/context/ToastContext";
function PostActions({ post }) {
const user = useAuthStore((s) => s.user);
const navigate = useNavigate();
const toast = useToast();
const [deletePost, { isLoading: isDeleting }] = useDeletePostMutation();
// Ownership check: is the current user the post author?
const isOwner = user?.id === post.author_id;
// Admin check: can this user manage any post?
const isAdmin = user?.role === "admin";
// Combined: show actions if owner OR admin
const canManage = isOwner || isAdmin;
if (!canManage) return null; // non-owners see no action buttons
async function handleDelete() {
if (!window.confirm("Delete this post?")) return;
try {
await deletePost(post.id).unwrap();
toast.success("Post deleted");
navigate("/dashboard", { replace: true });
} catch (err) {
toast.error("Failed to delete post");
}
}
return (
<div className="flex gap-2">
<button
onClick={() => navigate(`/posts/${post.id}/edit`)}
className="px-3 py-1 text-sm border rounded hover:bg-gray-50"
>
Edit
</button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="px-3 py-1 text-sm bg-red-600 text-white rounded
hover:bg-red-700 disabled:opacity-50"
>
{isDeleting ? "Deleting…" : "Delete"}
</button>
</div>
);
}
require_owner service method. Even if a user bypasses the hidden buttons (e.g., by sending a direct API request with curl), the backend returns 403 Forbidden. The frontend check prevents confusing UI; the backend check prevents actual unauthorised modifications.usePermissions hook that centralises ownership and role checks: function usePermissions(resource) { const user = useAuthStore(s => s.user); return { canEdit: user?.id === resource?.author_id || user?.role === "admin", canDelete: user?.id === resource?.author_id || user?.role === "admin", isOwner: user?.id === resource?.author_id, isAdmin: user?.role === "admin" }; }. Components call const { canEdit } = usePermissions(post) — the logic is in one place and tested once.user?.id === post.author_id check when IDs are numbers vs strings. FastAPI returns integer IDs, but if your Zustand store or JWT payload stores the user ID as a string (e.g., "42" from the JWT sub claim), the strict equality check === fails: "42" === 42 is false. Always ensure IDs are the same type on both sides: Number(user.id) === Number(post.author_id) or parse the ID consistently in the auth store.Comment Form — Auth Guard
function CommentSection({ postId }) {
const isLoggedIn = useAuthStore((s) => Boolean(s.user));
return (
<section>
<CommentList postId={postId} />
{isLoggedIn ? (
<CommentForm postId={postId} />
) : (
<div className="border rounded-lg p-4 text-center text-gray-500">
<p>
<a href="/login" className="text-blue-600 hover:underline">Sign in</a>
{" "}to leave a comment
</p>
</div>
)}
</section>
);
}
ProtectedRoute Working with Zustand
// src/components/auth/ProtectedRoute.jsx — updated for Zustand
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuthStore } from "@/stores/authStore";
export default function ProtectedRoute({ requiredRole = null }) {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const location = useLocation();
// While init() is validating the stored token, show a spinner
if (isLoading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin h-8 w-8 border-2 border-blue-500
rounded-full border-t-transparent" />
</div>
);
}
// Not logged in
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
// Role check for admin-only routes
if (requiredRole && user.role !== requiredRole) {
return <Navigate to="/" replace />;
}
return <Outlet />;
}
// Usage in App.jsx:
// <Route element={<ProtectedRoute />}>
// <Route path="dashboard" element={<DashboardPage />} />
// </Route>
// <Route element={<ProtectedRoute requiredRole="admin" />}>
// <Route path="admin" element={<AdminPage />} />
// </Route>
Common Mistakes
Mistake 1 — Type mismatch in ownership check
❌ Wrong — “42” !== 42, so owner never matches:
const isOwner = user.id === post.author_id; // "42" !== 42 if types differ!
✅ Correct — ensure consistent types:
const isOwner = Number(user.id) === Number(post.author_id); // ✓
Mistake 2 — Relying only on frontend ownership check for security
❌ Wrong — only hiding the button, backend has no ownership check.
✅ Correct — backend ALWAYS independently verifies ownership on write operations.