From 56249402fb9b6d1a17042cbac6c5a3d5e4132eca Mon Sep 17 00:00:00 2001 From: Nora Date: Mon, 22 Jun 2026 15:12:05 -0600 Subject: [PATCH] feat(auth): RoleGate component --- src/auth/RoleGate.test.tsx | 80 ++++++++++++++++++++++++++++++++++++++ src/auth/RoleGate.tsx | 57 +++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 src/auth/RoleGate.test.tsx create mode 100644 src/auth/RoleGate.tsx diff --git a/src/auth/RoleGate.test.tsx b/src/auth/RoleGate.test.tsx new file mode 100644 index 0000000..1460124 --- /dev/null +++ b/src/auth/RoleGate.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render } from "@testing-library/react"; +import { cleanup } from "@testing-library/react"; + +vi.mock("./useAuth", () => ({ + useAuth: vi.fn(), +})); + +import { useAuth } from "./useAuth"; +import { RoleGate } from "./RoleGate"; + +function mockUser(role: "admin" | "user" | "viewer" | null) { + (useAuth as unknown as ReturnType).mockReturnValue({ + user: role ? { id: 1, username: "x", role } : null, + status: role ? "authenticated" : "unauthenticated", + }); +} + +describe("RoleGate", () => { + beforeEach(() => { + vi.resetAllMocks(); + cleanup(); + }); + + it("renders children when role is allowed", () => { + mockUser("admin"); + const { container } = render( + + + + ); + expect(container.querySelector("button")).not.toBeNull(); + expect(container.textContent).toContain("Do thing"); + }); + + it("renders disabled (via span wrap) when role is NOT allowed", () => { + mockUser("viewer"); + const { container } = render( + + + + ); + const buttons = container.querySelectorAll("button"); + expect(buttons.length).toBeGreaterThan(0); + // The single child element is wrapped in a span that carries the + // disabled visual signal — opacity-50 plus pointer-events-none so + // the inner control can't be clicked. + const wrapper = container.querySelector("span"); + expect(wrapper).not.toBeNull(); + expect(wrapper?.className).toContain("opacity-50"); + expect(wrapper?.className).toContain("pointer-events-none"); + // The wrapper also carries a native `title` attribute that + // explains why the affordance is disabled. + expect(wrapper?.getAttribute("title")).toMatch(/role \(viewer\) cannot perform this action/); + }); + + it("renders fallback when provided and role not allowed", () => { + mockUser("viewer"); + const { container } = render( + NO}> + + + ); + expect(container.textContent).toContain("NO"); + // Fallback path does NOT wrap children — original button is absent. + expect(container.querySelector("button")).toBeNull(); + }); + + it("renders nothing when user is null", () => { + mockUser(null); + const { container } = render( + + + + ); + expect(container.querySelector("button")).toBeNull(); + expect(container.textContent).not.toContain("Do thing"); + }); +}); diff --git a/src/auth/RoleGate.tsx b/src/auth/RoleGate.tsx new file mode 100644 index 0000000..28299ee --- /dev/null +++ b/src/auth/RoleGate.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from "react"; +import { useAuth } from "./useAuth"; + +type Role = "admin" | "user" | "viewer"; + +interface Props { + /** + * Roles allowed to interact with the gated affordance. The user's + * role must be in this list for children to render normally. + */ + allow: Role[]; + children: ReactNode; + /** + * What to render instead when the user lacks the role AND we don't + * want to show the disabled affordance at all (e.g. "contact your + * admin" hint, or just nothing). + */ + fallback?: ReactNode; +} + +/** + * RoleGate wraps any write affordance (parse button, resubmit action, + * "match selected", etc.) so it visibly disables itself when the + * current user's role isn't in `allow`. + * + * Behavior matrix: + * + * - user is null → render nothing (the route guard + * already redirects to /login; this + * is belt-and-braces). + * - user.role in `allow` → render children unchanged. + * - user.role NOT in `allow` and + * `fallback` is provided → render `fallback`. + * - user.role NOT in `allow` and + * no `fallback` → wrap children in a span carrying + * `opacity-50` + `pointer-events-none` + * + a `title` attribute that + * explains why. We use the native + * HTML `title` instead of a custom + * Tooltip component to keep this + * dependency-free and accessible + * by default. + */ +export function RoleGate({ allow, children, fallback }: Props) { + const { user } = useAuth(); + if (!user) return null; + if (allow.includes(user.role)) return <>{children}; + if (fallback !== undefined) return <>{fallback}; + return ( + + {children} + + ); +}