Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions docs/useSubscribeToRecordList.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,25 @@ import { useNotify, useListContext } from 'react-admin';
import { useSubscribeToRecordList } from '@react-admin/ra-realtime';

const ListWatcher = () => {
const notity = useNotify();
const notify = useNotify();
const { refetch, data } = useListContext();
useSubscribeToRecordList(event => {
switch (event.type) {
case 'created': {
notity('New movie created');
notify('New movie created');
refetch();
break;
}
case 'updated': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} updated`);
notify(`Movie #${event.payload.ids[0]} updated`);
refetch();
}
break;
}
case 'deleted': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} deleted`);
notify(`Movie #${event.payload.ids[0]} deleted`);
refetch();
}
break;
Expand Down Expand Up @@ -80,25 +80,25 @@ const MovieList = () => (
Whenever an event is published on the `resource/[resource]` topic, the function passed as the first argument will be called with the event as a parameter.

```jsx
const notity = useNotify();
const notify = useNotify();
const { refetch, data } = useListContext();
useSubscribeToRecordList(event => {
switch (event.type) {
case 'created': {
notity('New movie created');
notify('New movie created');
refetch();
break;
}
case 'updated': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} updated`);
notify(`Movie #${event.payload.ids[0]} updated`);
refetch();
}
break;
}
case 'deleted': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} deleted`);
notify(`Movie #${event.payload.ids[0]} deleted`);
refetch();
}
break;
Expand All @@ -110,33 +110,33 @@ useSubscribeToRecordList(event => {
**Tip**: Memoize the callback using `useCallback` to avoid unnecessary subscriptions/unsubscriptions.

```jsx
const notity = useNotify();
const notify = useNotify();
const { refetch, data } = useListContext();
const callback = useCallback(
event => {
switch (event.type) {
case 'created': {
notity('New movie created');
notify('New movie created');
refetch();
break;
}
case 'updated': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} updated`);
notify(`Movie #${event.payload.ids[0]} updated`);
refetch();
}
break;
}
case 'deleted': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} deleted`);
notify(`Movie #${event.payload.ids[0]} deleted`);
refetch();
}
break;
}
}
},
[data, refetch, notity]
[data, refetch, notify]
);
useSubscribeToRecordList(callback);
```
Expand Down
40 changes: 40 additions & 0 deletions docs_headless/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export default defineConfig({
'usepermissions',
'addrefreshauthtoauthprovider',
'addrefreshauthtodataprovider',
enterpriseEntry('canAccessWithPermissions'),
enterpriseEntry('getPermissionsFromRoles'),
],
},
{
Expand Down Expand Up @@ -203,6 +205,28 @@ export default defineConfig({
'usegetrecordrepresentation',
],
},
{
label: 'Realtime',
items: [
enterpriseEntry('usePublish'),
enterpriseEntry('useSubscribe'),
enterpriseEntry('useSubscribeCallback'),
enterpriseEntry('useSubscribeToRecord'),
enterpriseEntry('useSubscribeToRecordList'),
enterpriseEntry('useLock'),
enterpriseEntry('useUnlock'),
enterpriseEntry('useGetLock'),
enterpriseEntry('useGetLockLive'),
enterpriseEntry('useGetLocks'),
enterpriseEntry('useGetLocksLive'),
enterpriseEntry('useLockCallbacks'),
enterpriseEntry('useLockOnMount'),
enterpriseEntry('useLockOnCall'),
enterpriseEntry('useGetListLive'),
enterpriseEntry('useGetOneLive'),
enterpriseEntry('<LockStatusBase>'),
],
},
{
label: 'Recipes',
items: ['caching', 'unittesting'],
Expand Down Expand Up @@ -240,3 +264,19 @@ export default defineConfig({
assets: 'assets',
},
});

/**
* @param {string} name
* @returns {any}
*/
function enterpriseEntry(name) {
return {
link: `${name.toLowerCase().replace(/</g, '').replace(/>/g, '')}/`,
label: name,
attrs: { class: 'enterprise' },
badge: {
text: 'React Admin Enterprise',
variant: 'default',
},
};
}
1 change: 1 addition & 0 deletions docs_headless/public/premium-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs_headless/public/premium-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
94 changes: 94 additions & 0 deletions docs_headless/src/content/docs/LockStatusBase.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
title: "<LockStatusBase>"
---

`<LockStatusBase>` displays the lock status of the current record. It allows to visually indicate whether the record is locked or not, by the current user or not, and provides an easy way to lock or unlock the record.

This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription.

## Installation

```bash
npm install --save @react-admin/ra-core-ee
# or
yarn add @react-admin/ra-core-ee
```

## Usage

```tsx
import React from 'react';
import { Lock, LockOpen, LoaderCircle } from 'lucide-react';
import { LockStatusBase } from '@react-admin/ra-core-ee';

export const LockStatus = () => {
return (
<LockStatusBase
{...props}
render={({
doLock,
doUnlock,
isLocking,
isPending,
isUnlocking,
lockStatus,
message,
}) => {
if (isPending) {
return null;
}

if (lockStatus === 'lockedByUser') {
return (
<button
title={message}
disabled={isUnlocking}
onClick={(
e: React.MouseEvent<HTMLButtonElement>
) => {
e.stopPropagation();
doUnlock();
}}
>
{isUnlocking ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Lock className="h-4 w-4" />
)}
</button>
);
}
if (lockStatus === 'lockedByAnotherUser') {
return (
<Lock className="h-4 w-4 text-error" />
);
}
if (lockStatus === 'unlocked') {
return (
<button
title={message}
disabled={isLocking}
onClick={(
e: React.MouseEvent<HTMLButtonElement>
) => {
e.stopPropagation();
doLock();
}}
color="warning"
>
{isLocking ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<LockOpen className="h-4 w-4" />
)}
</button>
);
}
return null;
}}
/>
);
};
```

In addition to the [`useLockCallbacks`](./useLockCallbacks.md) parameters, `<LockStatusBase>` accepts a `render` prop. The function passed to the `render` prop will be called with the result of the `useLockCallbacks` hook.
127 changes: 127 additions & 0 deletions docs_headless/src/content/docs/canAccessWithPermissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
---
title: "canAccessWithPermissions"
---

`canAccessWithPermissions` is a helper function that facilitates the implementation of [Access Control](./Permissions.md#access-control) policies based on an underlying list of user roles and permissions.

It is a builder block to implement the `authProvider.canAccess()` method, which is called by ra-core to check whether the current user has the right to perform a given action on a given resource or record.

This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription.

## Installation

```bash
npm install --save @react-admin/ra-core-ee
# or
yarn add @react-admin/ra-core-ee
```

## Usage

`canAccessWithPermissions` is a pure function that you can call from your `authProvider.canAccess()` implementation.

```tsx
import { canAccessWithPermissions } from '@react-admin/ra-core-ee';

const authProvider = {
// ...
canAccess: async ({ action, resource, record }) => {
const permissions = myGetPermissionsFunction();
return canAccessWithPermissions({
permissions,
action,
resource,
record,
});
}
// ...
};
```

The `permissions` parameter must be an array of permissions. A *permission* is an object that represents access to a subset of the application. It is defined by a `resource` (usually a noun) and an `action` (usually a verb), with sometimes an additional `record`.

Here are a few examples of permissions:

- `{ action: "*", resource: "*" }`: allow everything
- `{ action: "read", resource: "*" }`: allow read actions on all resources
- `{ action: "read", resource: ["companies", "people"] }`: allow read actions on a subset of resources
- `{ action: ["read", "create", "edit", "export"], resource: "companies" }`: allow all actions except delete on companies
- `{ action: ["write"], resource: "game.score", record: { "id": "123" } }`: allow write action on the score of the game with id 123

:::tip
When the `record` field is omitted, the permission is valid for all records.
:::

In most cases, the permissions are derived from user roles, which are fetched at login and stored in memory or in localStorage. Check the [`getPermissionsFromRoles`](./getPermissionsFromRoles.md) function to merge the permissions from multiple roles into a single flat array of permissions.

## Parameters

This function takes an object as argument with the following fields:

| Name | Optional | Type | Description
| - | - | - | - |
| `permissions` | Required | `Array<Permission>` | An array of permissions for the current user
| `action` | Required | `string` | The action for which to check users has the execution right
| `resource` | Required | `string` | The resource for which to check users has the execution right
| `record` | Required | `string` | The record for which to check users has the execution right

`canAccessWithPermissions` expects the `permissions` to be a flat array of permissions. It is your responsibility to fetch these permissions (usually during login). If the permissions are spread into several role definitions, you can merge them into a single array using the [`getPermissionsFromRoles`](./getPermissionsFromRoles.md) function.

## Building RBAC

The following example shows how to implement Role-based Access Control (RBAC) in `authProvider.canAccess()` using `canAccessWithPermissions` and `getPermissionsFromRoles`. The role permissions are defined in the code, and the user roles are returned by the authentication endpoint. Additional user permissions can also be returned by the authentication endpoint.

The `authProvider` stores the permissions in `localStorage`, so that returning users can access their permissions without having to log in again.

```tsx
// in roleDefinitions.ts
export const roleDefinitions = {
admin: [
{ action: '*', resource: '*' }
],
reader: [
{ action: ['list', 'show', 'export'], resource: '*' },
{ action: 'read', resource: 'posts.*' },
{ action: 'read', resource: 'comments.*' },
],
accounting: [
{ action: '*', resource: 'sales' },
],
};

// in authProvider.ts
import { canAccessWithPermissions, getPermissionsFromRoles } from '@react-admin/ra-core-ee';
import { roleDefinitions } from './roleDefinitions';

const authProvider = {
login: async ({ username, password }) => {
const request = new Request('https://mydomain.com/authenticate', {
method: 'POST',
body: JSON.stringify({ username, password }),
headers: new Headers({ 'Content-Type': 'application/json' }),
});
const response = await fetch(request);
if (response.status < 200 || response.status >= 300) {
throw new Error(response.statusText);
}
const { user: { roles, permissions }} = await response.json();
// merge the permissions from the roles with the extra permissions
const permissions = getPermissionsFromRoles({
roleDefinitions,
userPermissions,
userRoles
});
localStorage.setItem('permissions', JSON.stringify(permissions));
},
canAccess: async ({ action, resource, record }) => {
const permissions = JSON.parse(localStorage.getItem('permissions'));
return canAccessWithPermissions({
permissions,
action,
resource,
record,
});
}
// ...
};
```
Loading