mirror of
https://github.com/stefanpejcic/openpanel
synced 2025-06-26 18:28:26 +00:00
packages
This commit is contained in:
2
packages/core/src/hooks/accessControl/index.ts
Normal file
2
packages/core/src/hooks/accessControl/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./useCan";
|
||||
export * from "./useCanWithoutCache";
|
||||
335
packages/core/src/hooks/accessControl/useCan/index.spec.tsx
Normal file
335
packages/core/src/hooks/accessControl/useCan/index.spec.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import { TestWrapper, queryClient } from "@test";
|
||||
|
||||
import { useCan } from ".";
|
||||
import { useCanWithoutCache } from "..";
|
||||
|
||||
describe("useCan Hook", () => {
|
||||
it("can should return the true ", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCan({
|
||||
action: "list",
|
||||
resource: "posts",
|
||||
params: { id: 1 },
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
accessControlProvider: {
|
||||
can: ({ resource, action, params }) => {
|
||||
if (
|
||||
action === "list" &&
|
||||
resource === "posts" &&
|
||||
params?.id === 1
|
||||
) {
|
||||
return Promise.resolve({
|
||||
can: true,
|
||||
reason: "Access granted",
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ can: false });
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current?.isFetched).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(result.current?.data?.can).toBeTruthy();
|
||||
expect(result.current?.data?.reason).toBe("Access granted");
|
||||
});
|
||||
|
||||
it("can should return the false ", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCan({
|
||||
action: "list",
|
||||
resource: "posts",
|
||||
params: { id: 10 },
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
accessControlProvider: {
|
||||
can: ({ resource, action, params }) => {
|
||||
if (
|
||||
action === "list" &&
|
||||
resource === "posts" &&
|
||||
params?.id === 1
|
||||
) {
|
||||
return Promise.resolve({
|
||||
can: true,
|
||||
reason: "Access granted",
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
can: false,
|
||||
reason: "Access Denied",
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current?.isFetched).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(result.current?.data?.can).toBeFalsy();
|
||||
expect(result.current?.data?.reason).toBe("Access Denied");
|
||||
});
|
||||
|
||||
it("can should return the true if can is undefined ", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCan({
|
||||
action: "list",
|
||||
resource: "posts",
|
||||
params: { id: 1 },
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
accessControlProvider: {
|
||||
can: undefined,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current).toEqual({ data: { can: true } });
|
||||
});
|
||||
|
||||
it("can should sanitize resource icon ", async () => {
|
||||
const mockFn = jest.fn();
|
||||
renderHook(
|
||||
() =>
|
||||
useCan({
|
||||
action: "list",
|
||||
resource: "posts",
|
||||
params: {
|
||||
id: 1,
|
||||
resource: { icon: "test", name: "posts" } as any,
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
accessControlProvider: {
|
||||
can: mockFn,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockFn).toBeCalledWith({
|
||||
action: "list",
|
||||
params: {
|
||||
id: 1,
|
||||
resource: { name: "posts" },
|
||||
},
|
||||
resource: "posts",
|
||||
});
|
||||
});
|
||||
|
||||
it("should be disable by queryOptions", async () => {
|
||||
const mockFn = jest.fn();
|
||||
renderHook(
|
||||
() =>
|
||||
useCan({
|
||||
action: "list",
|
||||
resource: "posts",
|
||||
params: { id: 1 },
|
||||
queryOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
accessControlProvider: {
|
||||
can: mockFn,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockFn).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("should not throw error when accessControlProvider is undefined", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCan({
|
||||
action: "list",
|
||||
resource: "posts",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual({ can: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("should override `queryKey` with `queryOptions.queryKey`", async () => {
|
||||
const canMock = jest.fn().mockResolvedValue({
|
||||
can: true,
|
||||
reason: "Access granted",
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCan({
|
||||
action: "list",
|
||||
resource: "posts",
|
||||
queryOptions: {
|
||||
queryKey: ["foo", "bar"],
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
accessControlProvider: {
|
||||
can: canMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(
|
||||
queryClient.getQueryCache().findAll({
|
||||
queryKey: ["foo", "bar"],
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should override `queryFn` with `queryOptions.queryFn`", async () => {
|
||||
const canMock = jest.fn().mockResolvedValue({
|
||||
can: true,
|
||||
reason: "Access granted",
|
||||
});
|
||||
const queryFnMock = jest.fn().mockResolvedValue({
|
||||
can: true,
|
||||
reason: "Access granted",
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCan({
|
||||
action: "list",
|
||||
resource: "posts",
|
||||
queryOptions: {
|
||||
queryFn: queryFnMock,
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
accessControlProvider: {
|
||||
can: canMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(canMock).not.toBeCalled();
|
||||
expect(queryFnMock).toBeCalled();
|
||||
});
|
||||
|
||||
it("should use global queryOptions from AccessControlContext", async () => {
|
||||
const mockFn = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ can: true, reason: "Access granted" });
|
||||
const globalQueryOptions = { enabled: false };
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useCan({ action: "list", resource: "posts" }),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
accessControlProvider: {
|
||||
can: mockFn,
|
||||
options: { queryOptions: globalQueryOptions },
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isFetched).toBeFalsy();
|
||||
});
|
||||
|
||||
expect(mockFn).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCanWithoutCache", () => {
|
||||
it("should return the can function from the AccessControlContext", () => {
|
||||
const canMock = jest.fn();
|
||||
|
||||
const { result } = renderHook(() => useCanWithoutCache(), {
|
||||
wrapper: TestWrapper({
|
||||
accessControlProvider: {
|
||||
can: canMock,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
result.current.can?.({
|
||||
action: "list",
|
||||
resource: "posts",
|
||||
});
|
||||
|
||||
expect(canMock).toBeCalledWith({
|
||||
action: "list",
|
||||
resource: "posts",
|
||||
});
|
||||
});
|
||||
it("should sanitize the `resource` if provided", () => {
|
||||
const canMock = jest.fn();
|
||||
|
||||
const { result } = renderHook(() => useCanWithoutCache(), {
|
||||
wrapper: TestWrapper({
|
||||
accessControlProvider: {
|
||||
can: canMock,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
result.current.can?.({
|
||||
action: "list",
|
||||
resource: "posts",
|
||||
params: {
|
||||
id: 1,
|
||||
resource: {
|
||||
name: "posts",
|
||||
meta: { icon: "test" },
|
||||
options: { icon: "test" },
|
||||
icon: "test",
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(canMock).toBeCalledWith({
|
||||
action: "list",
|
||||
resource: "posts",
|
||||
params: {
|
||||
id: 1,
|
||||
resource: {
|
||||
name: "posts",
|
||||
meta: {},
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
89
packages/core/src/hooks/accessControl/useCan/index.ts
Normal file
89
packages/core/src/hooks/accessControl/useCan/index.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import { getXRay } from "@refinedev/devtools-internal";
|
||||
import {
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { AccessControlContext } from "@contexts/accessControl";
|
||||
import { sanitizeResource } from "@definitions/helpers/sanitize-resource";
|
||||
import { useKeys } from "@hooks/useKeys";
|
||||
import type {
|
||||
CanParams,
|
||||
CanReturnType,
|
||||
} from "../../../contexts/accessControl/types";
|
||||
|
||||
export type UseCanProps = CanParams & {
|
||||
/**
|
||||
* react-query's [useQuery](https://tanstack.com/query/v4/docs/reference/useQuery) options
|
||||
*/
|
||||
queryOptions?: UseQueryOptions<CanReturnType>;
|
||||
};
|
||||
|
||||
/**
|
||||
* `useCan` uses the `can` as the query function for `react-query`'s {@link https://react-query.tanstack.com/guides/queries `useQuery`}. It takes the parameters that `can` takes. It can also be configured with `queryOptions` for `useQuery`. Returns the result of `useQuery`.
|
||||
* @see {@link https://refine.dev/docs/api-reference/core/hooks/accessControl/useCan} for more details.
|
||||
*
|
||||
* @typeParam CanParams {@link https://refine.dev/docs/core/interfaceReferences#canparams}
|
||||
* @typeParam CanReturnType {@link https://refine.dev/docs/core/interfaceReferences#canreturntype}
|
||||
*
|
||||
*/
|
||||
export const useCan = ({
|
||||
action,
|
||||
resource,
|
||||
params,
|
||||
queryOptions: hookQueryOptions,
|
||||
}: UseCanProps): UseQueryResult<CanReturnType> => {
|
||||
const { can, options: globalOptions } = useContext(AccessControlContext);
|
||||
const { keys, preferLegacyKeys } = useKeys();
|
||||
|
||||
const { queryOptions: globalQueryOptions } = globalOptions || {};
|
||||
|
||||
const mergedQueryOptions = {
|
||||
...globalQueryOptions,
|
||||
...hookQueryOptions,
|
||||
};
|
||||
|
||||
/**
|
||||
* Since `react-query` stringifies the query keys, it will throw an error for a circular dependency if we include `React.ReactNode` elements inside the keys.
|
||||
* The feature in #2220(https://github.com/refinedev/refine/issues/2220) includes such change and to fix this, we need to remove `icon` property in the `resource`
|
||||
*/
|
||||
const { resource: _resource, ...paramsRest } = params ?? {};
|
||||
|
||||
const sanitizedResource = sanitizeResource(_resource);
|
||||
|
||||
const queryResponse = useQuery<CanReturnType>({
|
||||
queryKey: keys()
|
||||
.access()
|
||||
.resource(resource)
|
||||
.action(action)
|
||||
.params({
|
||||
params: { ...paramsRest, resource: sanitizedResource },
|
||||
enabled: mergedQueryOptions?.enabled,
|
||||
})
|
||||
.get(preferLegacyKeys),
|
||||
// Enabled check for `can` is enough to be sure that it's defined in the query function but TS is not smart enough to know that.
|
||||
queryFn: () =>
|
||||
can?.({
|
||||
action,
|
||||
resource,
|
||||
params: { ...paramsRest, resource: sanitizedResource },
|
||||
}) ?? Promise.resolve({ can: true }),
|
||||
enabled: typeof can !== "undefined",
|
||||
...mergedQueryOptions,
|
||||
meta: {
|
||||
...mergedQueryOptions?.meta,
|
||||
...getXRay("useCan", preferLegacyKeys, resource, [
|
||||
"useButtonCanAccess",
|
||||
"useNavigationButton",
|
||||
]),
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return typeof can === "undefined"
|
||||
? ({ data: { can: true } } as typeof queryResponse)
|
||||
: queryResponse;
|
||||
};
|
||||
39
packages/core/src/hooks/accessControl/useCanWithoutCache.ts
Normal file
39
packages/core/src/hooks/accessControl/useCanWithoutCache.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
|
||||
import { AccessControlContext } from "@contexts/accessControl";
|
||||
import { sanitizeResource } from "@definitions/helpers/sanitize-resource";
|
||||
|
||||
import type { IAccessControlContext } from "../../contexts/accessControl/types";
|
||||
|
||||
export const useCanWithoutCache = (): IAccessControlContext => {
|
||||
const { can: canFromContext } = React.useContext(AccessControlContext);
|
||||
|
||||
const can = React.useMemo(() => {
|
||||
if (!canFromContext) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const canWithSanitizedResource: NonNullable<typeof canFromContext> =
|
||||
async ({ params, ...rest }) => {
|
||||
const sanitizedResource = params?.resource
|
||||
? sanitizeResource(params.resource)
|
||||
: undefined;
|
||||
|
||||
return canFromContext({
|
||||
...rest,
|
||||
...(params
|
||||
? {
|
||||
params: {
|
||||
...params,
|
||||
resource: sanitizedResource,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
return canWithSanitizedResource;
|
||||
}, [canFromContext]);
|
||||
|
||||
return { can };
|
||||
};
|
||||
Reference in New Issue
Block a user