Protected UI — Ownership-Based Rendering and Route Guards

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>
    );
}
Note: Client-side ownership checks (hiding buttons) are a UX convenience only — they must never be the sole security mechanism. The FastAPI backend independently verifies ownership on every PATCH and DELETE request via the 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.
Tip: Create a reusable 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.
Warning: Be careful with the 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.

🧠 Test Yourself

A user opens a post and sees no Edit button (they are not the owner). Could they still edit the post by sending a direct PATCH request to the API? Why or why not?