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