feat(auth): RoleGate component
This commit is contained in:
@@ -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<typeof vi.fn>).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(
|
||||||
|
<RoleGate allow={["admin", "user"]}>
|
||||||
|
<button>Do thing</button>
|
||||||
|
</RoleGate>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<RoleGate allow={["admin", "user"]}>
|
||||||
|
<button>Do thing</button>
|
||||||
|
</RoleGate>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<RoleGate allow={["admin"]} fallback={<span>NO</span>}>
|
||||||
|
<button>Do thing</button>
|
||||||
|
</RoleGate>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<RoleGate allow={["admin", "user"]}>
|
||||||
|
<button>Do thing</button>
|
||||||
|
</RoleGate>
|
||||||
|
);
|
||||||
|
expect(container.querySelector("button")).toBeNull();
|
||||||
|
expect(container.textContent).not.toContain("Do thing");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<span
|
||||||
|
className="pointer-events-none opacity-50 inline-flex"
|
||||||
|
title={`Your role (${user.role}) cannot perform this action.`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user