58 lines
2.1 KiB
TypeScript
58 lines
2.1 KiB
TypeScript
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>
|
|
);
|
|
}
|