@cerbos/react
Version:
A collection of React hooks for interacting with Cerbos policy decision points
253 lines (234 loc) • 6.44 kB
text/typescript
import { useCallback, useEffect, useState } from "react";
import { useDeepCompareMemoize } from "use-deep-compare-effect";
import type {
CheckResourceRequest,
CheckResourcesRequest,
CheckResourcesResponse,
CheckResourcesResult,
ClientWithPrincipal,
IsAllowedRequest,
RequestOptions,
} from "@cerbos/core";
import { useCerbos } from "./use-cerbos";
/**
* The result of calling an async method on a client.
*
* @public
*/
export type AsyncResult<T> =
| { isLoading: true; data: undefined; error: undefined }
| { isLoading: false; data: T; error: undefined }
| { isLoading: false; data: undefined; error: Error };
type Methods = "checkResource" | "checkResources" | "isAllowed";
type Result<Method extends Methods> = Awaited<
ReturnType<ClientWithPrincipal[Method]>
>;
function useCerbosRequest<Method extends Methods>(
method: Method,
request: Parameters<ClientWithPrincipal[Method]>[0],
options?: Omit<RequestOptions, "signal">,
): AsyncResult<Result<Method>> {
const [isLoading, setIsLoading] = useState(true);
const [data, setData] = useState<Result<Method>>();
const [error, setError] = useState<Error>();
const client = useCerbos();
const requestMemo = useDeepCompareMemoize(request);
const optionsMemo = useDeepCompareMemoize(options);
const load = useCallback<(signal: AbortSignal) => Promise<Result<Method>>>(
async (signal: AbortSignal): Promise<Result<Method>> =>
// @ts-expect-error -- https://github.com/microsoft/TypeScript/issues/30581
await client[method](requestMemo, {
...optionsMemo,
signal,
}),
[client, method, optionsMemo, requestMemo],
);
const bundle = activeEmbeddedBundle(client);
useEffect(() => {
setIsLoading(true);
setData(undefined);
setError(undefined);
const abortController = new AbortController();
load(abortController.signal)
.then((data) => {
if (!abortController.signal.aborted) {
setIsLoading(false);
setData(data);
setError(undefined);
}
})
.catch((error: unknown) => {
if (!abortController.signal.aborted) {
setIsLoading(false);
setData(undefined);
setError(
error instanceof Error
? error
: new Error("An unexpected error occurred", { cause: error }),
);
}
});
return (): void => {
abortController.abort();
};
}, [load, bundle]);
return { isLoading, data, error } as AsyncResult<Result<Method>>;
}
function activeEmbeddedBundle({ client }: ClientWithPrincipal): unknown {
return "loader" in client &&
typeof client.loader === "object" &&
client.loader &&
"_active" in client.loader
? client.loader._active
: undefined;
}
/**
* Check the principal's permissions on a resource.
*
* @example
* ```typescript
* import { useCheckResource } from "@cerbos/react";
*
* function SomeComponent() {
* const check = useCheckResource({
* resource: {
* kind: "document",
* id: "1",
* attr: { owner: "user@example.com" },
* },
* actions: ["view", "edit"],
* });
*
* if (check.isLoading) {
* // show spinner
* return "Loading...";
* }
*
* if (check.error) {
* // handle error
* return "Error...";
* }
*
* return (
* <div>
* {check.data.allAllowed() && <button>a button</button>}
* {check.data.isAllowed("view") && <button>another button</button>}
* </div>
* );
* }
* ```
* @public
*/
export function useCheckResource(
request: Omit<CheckResourceRequest, "principal">,
options?: Omit<RequestOptions, "signal">,
): AsyncResult<CheckResourcesResult> {
return useCerbosRequest("checkResource", request, options);
}
/**
* Check the principal's permissions on a set of resources.
*
* @example
* ```typescript
* import { useCheckResources } from "@cerbos/react";
*
* function SomeComponent() {
* const check = useCheckResources({
* resources: [
* {
* resource: {
* kind: "document",
* id: "1",
* attr: { owner: "user@example.com" },
* },
* actions: ["view", "edit"],
* },
* {
* resource: {
* kind: "document",
* id: "2",
* attr: { owner: "another-user@example.com" },
* },
* actions: ["view", "edit"],
* },
* ],
* });
*
* if (check.isLoading) {
* // show spinner
* return "Loading...";
* }
*
* if (check.error) {
* // handle error
* return "Error...";
* }
*
* return (
* <div>
* {check.data.allAllowed({
* kind: "document",
* id: "1",
* }) && <button>a button document 1</button>}
* {check.data.allAllowed({
* kind: "document",
* id: "2",
* }) && <button>a button document 2</button>}
* {check.data.isAllowed({
* resource: { kind: "document", id: "1" },
* action: "edit",
* }) && <button>another button for document 1</button>}
* {check.data.isAllowed({
* resource: { kind: "document", id: "2" },
* action: "edit",
* }) && <button>another button for document 2</button>}
* </div>
* );
* }
* ```
* @public
*/
export function useCheckResources(
request: Omit<CheckResourcesRequest, "principal">,
options?: Omit<RequestOptions, "signal">,
): AsyncResult<CheckResourcesResponse> {
return useCerbosRequest("checkResources", request, options);
}
/**
* Check if the principal is allowed to perform an action on a resource.
*
* @example
* ```typescript
* import { useIsAllowed } from "@cerbos/react";
*
* function SomeComponent() {
* const check = useIsAllowed({
* resource: {
* kind: "document",
* id: "1",
* attr: { owner: "user@example.com" },
* },
* action: "view",
* });
*
* if (check.isLoading) {
* // show spinner
* return "Loading...";
* }
*
* if (check.error) {
* // handle error
* return "Error...";
* }
*
* return <div>{check.data && <button>a button document 1</button>}</div>;
* }
* ```
* @public
*/
export function useIsAllowed(
request: Omit<IsAllowedRequest, "principal">,
options?: Omit<RequestOptions, "signal">,
): AsyncResult<boolean> {
return useCerbosRequest("isAllowed", request, options);
}