Skip to content

Commit

Permalink
Add <CanAccess>
Browse files Browse the repository at this point in the history
  • Loading branch information
djhi committed Sep 19, 2024
1 parent 2685254 commit 7dbc256
Show file tree
Hide file tree
Showing 6 changed files with 231 additions and 37 deletions.
44 changes: 44 additions & 0 deletions docs/CanAccess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
layout: default
title: "CanAccess"
---

# `CanAccess`

This component calls the `authProvider.canAccess()` method on mount for a provided resource and action (and optionally a record). It will only display its children when users are authorized.

## Usage

```jsx
import { CanAccess, Edit, SimpleForm } from 'react-admin';

const UserEdit = () => {
return (
<Edit>
<SimpleForm>
<TextInput source="lastName">
<TextInput source="firstName">
<CanAccess action="editPermissions">
<SelectInput source="role" choices={['admin', 'user']}>
</CanAccess>
</SimpleForm>
</Edit>
)
};
```

`<CanAccess>` will call the `authProvider.canAccess` method with the following parameters: `{ action: "editPermissions", resource: "users", record: {} }` where `record` wil be the currently edited record.

## Parameters

`<CanAccess>` expects the following props:

| Name | Required | Type | Default | Description |
| -------------- | -------- | -------------- | ------------------------------------- | --- |
| `action` | Required | `string` | - | The action to check, e.g. 'read', 'list', 'export', 'delete', etc. |
| `resource` | Optional | `string` | Resource from current ResourceContext | The resource to check, e.g. 'users', 'comments', 'posts', etc. |
| `record` | Optional | `object` | Record from current RecordContext | The record to check. If passed, the child only renders if the user has permissions for that record, e.g. `{ id: 123, firstName: "John", lastName: "Doe" }` |
| `loading` | Optional | `ReactElement` | `loading` from `Admin>` | The element displayed when authorizations are being checked |
| `unauthorized` | Optional | `ReactElement` | `unauthorized` from `Admin>` | The element displayed when users are not authorized to see a page |


36 changes: 36 additions & 0 deletions packages/ra-core/src/auth/CanAccess.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import {
Basic,
CustomLoading,
CustomUnauthorized,
NoAuthProvider,
Unauthorized,
} from './CanAccess.stories';

describe('CanAccess', () => {
it('shows the default loading component while loading', async () => {
render(<Basic />);
await screen.findByText('Loading...');
});
it('shows the custom loading element while loading', async () => {
render(<CustomLoading />);
await screen.findByText('Please wait...');
});
it('shows the default unauthorized component when users are unauthorized', async () => {
render(<Unauthorized />);
await screen.findByText('Loading...');
});
it('shows the custom unauthorized element when users are unauthorized', async () => {
render(<CustomUnauthorized />);
await screen.findByText('Not allowed');
});
it('shows the protected content when users are authorized', async () => {
render(<Basic />);
await screen.findByText('protected content');
});
it('shows the protected content when no authProvider', () => {
render(<NoAuthProvider />);
screen.getByText('protected content');
});
});
86 changes: 86 additions & 0 deletions packages/ra-core/src/auth/CanAccess.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import * as React from 'react';
import { AuthProvider } from '../types';
import { CoreAdminContext } from '../core';
import { CanAccess } from './CanAccess';

export default {
title: 'ra-core/auth/CanAccess',
};

const defaultAuthProvider: AuthProvider = {
login: () => Promise.reject('bad method'),
logout: () => Promise.reject('bad method'),
checkAuth: () => Promise.reject('bad method'),
checkError: () => Promise.reject('bad method'),
getPermissions: () => Promise.reject('bad method'),
canAccess: ({ action }) =>
new Promise(resolve => setTimeout(resolve, 500, action === 'read')),
};

export const Basic = () => (
<CoreAdminContext
authProvider={defaultAuthProvider}
loading={() => <div>Loading...</div>}
unauthorized={() => <div>Unauthorized</div>}
>
<CanAccess action="read" resource="test">
protected content
</CanAccess>
</CoreAdminContext>
);

export const Unauthorized = () => (
<CoreAdminContext
authProvider={defaultAuthProvider}
loading={() => <div>Loading...</div>}
unauthorized={() => <div>Unauthorized</div>}
>
<CanAccess action="show" resource="test">
protected content
</CanAccess>
</CoreAdminContext>
);

export const CustomLoading = () => (
<CoreAdminContext
authProvider={defaultAuthProvider}
loading={() => <div>Loading...</div>}
unauthorized={() => <div>Unauthorized</div>}
>
<CanAccess
action="read"
resource="test"
loading={<div>Please wait...</div>}
>
protected content
</CanAccess>
</CoreAdminContext>
);

export const CustomUnauthorized = () => (
<CoreAdminContext
authProvider={defaultAuthProvider}
loading={() => <div>Loading...</div>}
unauthorized={() => <div>Unauthorized</div>}
>
<CanAccess
action="show"
resource="test"
unauthorized={<div>Not allowed</div>}
>
protected content
</CanAccess>
</CoreAdminContext>
);

export const NoAuthProvider = () => (
<CoreAdminContext
authProvider={undefined}
loading={() => <div>Loading...</div>}
unauthorized={() => <div>Unauthorized</div>}
>
<CanAccess action="read" resource="test">
protected content
</CanAccess>
</CoreAdminContext>
);
55 changes: 55 additions & 0 deletions packages/ra-core/src/auth/CanAccess.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as React from 'react';
import { useLoadingContext } from '../core/useLoadingContext';
import { useUnauthorizedContext } from '../core/useUnauthorizedContext';
import { useCanAccess } from './useCanAccess';
import { RaRecord } from '../types';
import { useRecordContext } from '../controller';
import { useResourceContext } from '../core';

/**
* A component that only displays its children after checking whether users are authorized to access the provided resource and action.
* @param options
* @param options.action The action to check. One of 'list', 'create', 'edit', 'show', 'delete', or a custom action.
* @param options.resource The resource to check. e.g. 'posts', 'comments', 'users'
* @param options.children The component to render if users are authorized.
* @param options.loading An optional element to render while the authorization is being checked. Defaults to the loading component provided on `Admin`.
* @param options.unauthorized An optional element to render if users are not authorized. Defaults to the unauthorized component provided on `Admin`.
*/
export const CanAccess = ({
action,
children,
loading,
unauthorized,
...props
}: CanAccessProps) => {
const resource = useResourceContext(props);
if (!resource) {
throw new Error(
'<CanAccess> must be used inside a <Resource> component or provide a resource prop'
);
}
const record = useRecordContext(props);
const { canAccess, isPending } = useCanAccess({
action,
resource,
record,
});

const Loading = useLoadingContext();
const Unauthorized = useUnauthorizedContext();

return isPending
? loading ?? <Loading />
: canAccess === false
? unauthorized ?? <Unauthorized />
: children;
};

export interface CanAccessProps {
action: string;
resource?: string;
record?: RaRecord;
children: React.ReactNode;
loading?: React.ReactElement;
unauthorized?: React.ReactElement;
}
1 change: 1 addition & 0 deletions packages/ra-core/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import convertLegacyAuthProvider from './convertLegacyAuthProvider';

export * from './Authenticated';
export * from './AuthContext';
export * from './CanAccess';
export * from './LogoutOnMount';
export * from './types';
export * from './useAuthenticated';
Expand Down
46 changes: 9 additions & 37 deletions packages/ra-core/src/core/Resource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ import { isValidElementType } from 'react-is';
import { ResourceProps } from '../types';
import { ResourceContextProvider } from './ResourceContextProvider';
import { RestoreScrollPosition } from '../routing/RestoreScrollPosition';
import { useCanAccess } from '../auth';
import { useLoadingContext } from './useLoadingContext';
import { useUnauthorizedContext } from './useUnauthorizedContext';
import { CanAccess } from '../auth/CanAccess';

export const Resource = (props: ResourceProps) => {
const { create, edit, list, name, show } = props;
Expand All @@ -20,43 +18,43 @@ export const Resource = (props: ResourceProps) => {
<Route
path="create/*"
element={
<ResourcePage action="create" resource={name}>
<CanAccess action="create" resource={name}>
{getElement(create)}
</ResourcePage>
</CanAccess>
}
/>
)}
{show && (
<Route
path=":id/show/*"
element={
<ResourcePage action="show" resource={name}>
<CanAccess action="show" resource={name}>
{getElement(show)}
</ResourcePage>
</CanAccess>
}
/>
)}
{edit && (
<Route
path=":id/*"
element={
<ResourcePage action="edit" resource={name}>
<CanAccess action="edit" resource={name}>
{getElement(edit)}
</ResourcePage>
</CanAccess>
}
/>
)}
{list && (
<Route
path="/*"
element={
<ResourcePage action="list" resource={name}>
<CanAccess action="list" resource={name}>
<RestoreScrollPosition
storeKey={`${name}.list.scrollPosition`}
>
{getElement(list)}
</RestoreScrollPosition>
</ResourcePage>
</CanAccess>
}
/>
)}
Expand All @@ -66,32 +64,6 @@ export const Resource = (props: ResourceProps) => {
);
};

const ResourcePage = ({
action,
resource,
children,
}: {
action: string;
resource: string;
children: React.ReactNode;
}) => {
const { canAccess, isPending } = useCanAccess({
action,
resource,
});

const Loading = useLoadingContext();
const Unauthorized = useUnauthorizedContext();

return isPending ? (
<Loading />
) : canAccess === false ? (
<Unauthorized />
) : (
children
);
};

const getElement = (ElementOrComponent: ComponentType<any> | ReactElement) => {
if (isValidElement(ElementOrComponent)) {
return ElementOrComponent;
Expand Down

0 comments on commit 7dbc256

Please sign in to comment.