Skip to content

Commit bf2c145

Browse files
authored
Merge pull request #10383 from marmelab/fix-access-control-basename-handling
Fix access control basename handling
2 parents 18881de + 8f2d3a2 commit bf2c145

File tree

10 files changed

+285
-55
lines changed

10 files changed

+285
-55
lines changed

packages/ra-core/src/auth/CanAccess.spec.tsx

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from 'react';
2-
import { render, screen } from '@testing-library/react';
2+
import { render, screen, waitFor } from '@testing-library/react';
3+
import { Location } from 'react-router';
34
import {
45
Basic,
56
CustomLoading,
@@ -89,6 +90,63 @@ describe('<CanAccess>', () => {
8990
resolveCheckAuth(false);
9091
await screen.findByText('Not allowed');
9192
});
93+
it('redirects to the /authentication-error route by default in case of error', async () => {
94+
let rejectCheckAuth;
95+
let location: Location;
96+
const authProvider: AuthProvider = {
97+
login: () => Promise.reject('bad method'),
98+
logout: () => Promise.reject('bad method'),
99+
checkAuth: () => Promise.reject('bad method'),
100+
checkError: () => Promise.reject('bad method'),
101+
getPermissions: () => Promise.reject('bad method'),
102+
canAccess: () =>
103+
new Promise((_, reject) => {
104+
rejectCheckAuth = reject;
105+
}),
106+
};
107+
const { container } = render(
108+
<Basic
109+
authProvider={authProvider}
110+
locationCallback={l => {
111+
location = l;
112+
}}
113+
/>
114+
);
115+
expect(container.textContent).toEqual('');
116+
rejectCheckAuth(new Error('failed'));
117+
await waitFor(() =>
118+
expect(location.pathname).toEqual('/authentication-error')
119+
);
120+
});
121+
it('redirects to the /authentication-error route by default in case of error in an Admin with a basename', async () => {
122+
let rejectCheckAuth;
123+
let location: Location;
124+
const authProvider: AuthProvider = {
125+
login: () => Promise.reject('bad method'),
126+
logout: () => Promise.reject('bad method'),
127+
checkAuth: () => Promise.reject('bad method'),
128+
checkError: () => Promise.reject('bad method'),
129+
getPermissions: () => Promise.reject('bad method'),
130+
canAccess: () =>
131+
new Promise((_, reject) => {
132+
rejectCheckAuth = reject;
133+
}),
134+
};
135+
const { container } = render(
136+
<Basic
137+
authProvider={authProvider}
138+
basename="/admin"
139+
locationCallback={l => {
140+
location = l;
141+
}}
142+
/>
143+
);
144+
expect(container.textContent).toEqual('');
145+
rejectCheckAuth(new Error('failed'));
146+
await waitFor(() =>
147+
expect(location.pathname).toEqual('/admin/authentication-error')
148+
);
149+
});
92150
it('shows the protected content when users are authorized', async () => {
93151
let resolveCheckAuth;
94152
const authProvider: AuthProvider = {
Lines changed: 62 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import * as React from 'react';
2+
import { Location } from 'react-router';
3+
import { QueryClient } from '@tanstack/react-query';
24
import { AuthProvider } from '../types';
35
import { CoreAdminContext } from '../core';
46
import { CanAccess } from './CanAccess';
7+
import { TestMemoryRouter } from '..';
58

69
export default {
710
title: 'ra-core/auth/CanAccess',
@@ -19,64 +22,90 @@ const defaultAuthProvider: AuthProvider = {
1922

2023
export const Basic = ({
2124
authProvider = defaultAuthProvider,
25+
basename,
26+
locationCallback,
2227
}: {
2328
authProvider?: AuthProvider;
29+
basename?: string;
30+
locationCallback?: (location: Location) => void;
2431
}) => (
25-
<CoreAdminContext authProvider={authProvider}>
26-
<CanAccess action="read" resource="test">
27-
protected content
28-
</CanAccess>
29-
</CoreAdminContext>
32+
<TestMemoryRouter locationCallback={locationCallback}>
33+
<CoreAdminContext
34+
authProvider={authProvider}
35+
basename={basename}
36+
queryClient={
37+
new QueryClient({
38+
defaultOptions: {
39+
queries: {
40+
retry: false,
41+
},
42+
},
43+
})
44+
}
45+
>
46+
<CanAccess action="read" resource="test">
47+
protected content
48+
</CanAccess>
49+
</CoreAdminContext>
50+
</TestMemoryRouter>
3051
);
3152

3253
export const AccessDenied = ({
3354
authProvider = defaultAuthProvider,
3455
}: {
3556
authProvider?: AuthProvider;
3657
}) => (
37-
<CoreAdminContext authProvider={authProvider}>
38-
<CanAccess action="show" resource="test">
39-
protected content
40-
</CanAccess>
41-
</CoreAdminContext>
58+
<TestMemoryRouter>
59+
<CoreAdminContext authProvider={authProvider}>
60+
<CanAccess action="show" resource="test">
61+
protected content
62+
</CanAccess>
63+
</CoreAdminContext>
64+
</TestMemoryRouter>
4265
);
4366

4467
export const CustomLoading = ({
4568
authProvider = defaultAuthProvider,
4669
}: {
4770
authProvider?: AuthProvider;
4871
}) => (
49-
<CoreAdminContext authProvider={authProvider}>
50-
<CanAccess
51-
action="read"
52-
resource="test"
53-
loading={<div>Please wait...</div>}
54-
>
55-
protected content
56-
</CanAccess>
57-
</CoreAdminContext>
72+
<TestMemoryRouter>
73+
<CoreAdminContext authProvider={authProvider}>
74+
<CanAccess
75+
action="read"
76+
resource="test"
77+
loading={<div>Please wait...</div>}
78+
>
79+
protected content
80+
</CanAccess>
81+
</CoreAdminContext>
82+
</TestMemoryRouter>
5883
);
5984

6085
export const CustomAccessDenied = ({
6186
authProvider = defaultAuthProvider,
6287
}: {
6388
authProvider?: AuthProvider;
6489
}) => (
65-
<CoreAdminContext authProvider={authProvider}>
66-
<CanAccess
67-
action="show"
68-
resource="test"
69-
accessDenied={<div>Not allowed</div>}
70-
>
71-
protected content
72-
</CanAccess>
73-
</CoreAdminContext>
90+
<TestMemoryRouter>
91+
<CoreAdminContext authProvider={authProvider}>
92+
<CanAccess
93+
action="show"
94+
resource="test"
95+
accessDenied={<div>Not allowed</div>}
96+
>
97+
protected content
98+
</CanAccess>
99+
</CoreAdminContext>
100+
</TestMemoryRouter>
74101
);
75102

76103
export const NoAuthProvider = () => (
77-
<CoreAdminContext authProvider={undefined}>
78-
<CanAccess action="read" resource="test">
79-
protected content
80-
</CanAccess>
81-
</CoreAdminContext>
104+
<TestMemoryRouter>
105+
<CoreAdminContext authProvider={undefined}>
106+
<CanAccess action="read" resource="test">
107+
protected content
108+
</CanAccess>
109+
</CoreAdminContext>
110+
</TestMemoryRouter>
82111
);

packages/ra-core/src/auth/CanAccess.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import * as React from 'react';
2+
import { Navigate } from 'react-router';
23
import { useCanAccess, UseCanAccessOptions } from './useCanAccess';
34
import { RaRecord } from '../types';
4-
import { Navigate } from 'react-router';
5+
import { useBasename } from '../routing';
56

67
/**
78
* A component that only displays its children after checking whether users are authorized to access the provided resource and action.
@@ -50,4 +51,9 @@ export interface CanAccessProps<
5051
error?: React.ReactNode;
5152
}
5253

53-
const DEFAULT_ERROR = <Navigate to="/authentication-error" />;
54+
const CanAccessDefaultError = () => {
55+
const basename = useBasename();
56+
return <Navigate to={`${basename}/authentication-error`} />;
57+
};
58+
59+
const DEFAULT_ERROR = <CanAccessDefaultError />;

packages/ra-core/src/auth/useRequireAccess.spec.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import * as React from 'react';
22
import expect from 'expect';
33
import { waitFor, render, screen } from '@testing-library/react';
4-
54
import { QueryClient } from '@tanstack/react-query';
5+
import { Location } from 'react-router';
66
import { Basic } from './useRequireAccess.stories';
77

88
describe('useRequireAccess', () => {
@@ -77,6 +77,55 @@ describe('useRequireAccess', () => {
7777
await screen.findByText('Authentication Error');
7878
});
7979

80+
it('should redirect to /access-denied when users do not have access in an Admin with basename', async () => {
81+
let location: Location;
82+
const authProvider = {
83+
login: () => Promise.reject('bad method'),
84+
logout: () => Promise.reject('bad method'),
85+
checkAuth: () => Promise.reject('bad method'),
86+
checkError: () => Promise.reject('bad method'),
87+
getPermissions: () => Promise.reject('bad method'),
88+
canAccess: () => Promise.resolve(false),
89+
};
90+
render(
91+
<Basic
92+
authProvider={authProvider}
93+
basename="/admin"
94+
locationCallback={l => {
95+
location = l;
96+
}}
97+
/>
98+
);
99+
100+
await waitFor(() => {
101+
expect(location.pathname).toEqual('/admin/access-denied');
102+
});
103+
});
104+
105+
it('should redirect to /authentication-error when auth.canAccess call fails in an Admin with basename', async () => {
106+
let location: Location;
107+
const authProvider = {
108+
login: () => Promise.reject('bad method'),
109+
logout: () => Promise.reject('bad method'),
110+
checkAuth: () => Promise.reject('bad method'),
111+
getPermissions: () => Promise.reject('bad method'),
112+
checkError: () => Promise.reject('bad method'),
113+
canAccess: () => Promise.reject('not good'),
114+
};
115+
render(
116+
<Basic
117+
authProvider={authProvider}
118+
basename="/admin"
119+
locationCallback={l => {
120+
location = l;
121+
}}
122+
/>
123+
);
124+
await waitFor(() => {
125+
expect(location.pathname).toEqual('/admin/authentication-error');
126+
});
127+
});
128+
80129
it('should abort the request if the query is canceled', async () => {
81130
const abort = jest.fn();
82131
const authProvider = {

packages/ra-core/src/auth/useRequireAccess.stories.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react';
22
import { QueryClient } from '@tanstack/react-query';
3-
import { Route, Routes } from 'react-router';
3+
import { Location, Route, Routes } from 'react-router';
44
import { AuthProvider } from '../types';
55
import { CoreAdminContext } from '../core';
66
import { useRequireAccess, UseRequireAccessResult } from './useRequireAccess';
@@ -50,15 +50,20 @@ const defaultAuthProvider: AuthProvider = {
5050

5151
export const Basic = ({
5252
authProvider = defaultAuthProvider,
53+
basename,
54+
locationCallback,
5355
queryClient,
5456
}: {
5557
authProvider?: AuthProvider | null;
58+
basename?: string;
59+
locationCallback?: (l: Location) => void;
5660
queryClient?: QueryClient;
5761
}) => (
58-
<TestMemoryRouter>
62+
<TestMemoryRouter locationCallback={locationCallback}>
5963
<CoreAdminContext
6064
authProvider={authProvider != null ? authProvider : undefined}
6165
queryClient={queryClient}
66+
basename={basename}
6267
>
6368
<Routes>
6469
<Route

packages/ra-core/src/auth/useRequireAccess.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { useEffect } from 'react';
2+
import { useNavigate } from 'react-router';
23
import { RaRecord } from '../types';
34
import {
45
useCanAccess,
56
UseCanAccessOptions,
67
UseCanAccessResult,
78
} from './useCanAccess';
8-
import { useNavigate } from 'react-router';
9+
import { useBasename } from '../routing';
910

1011
/**
1112
* A hook that calls the authProvider.canAccess() method for a provided resource and action (and optionally a record).
@@ -50,20 +51,21 @@ export const useRequireAccess = <
5051
) => {
5152
const { canAccess, data, error, ...rest } = useCanAccess(params);
5253
const navigate = useNavigate();
54+
const basename = useBasename();
5355

5456
useEffect(() => {
5557
if (rest.isPending) return;
5658

5759
if (canAccess === false) {
58-
navigate('/access-denied');
60+
navigate(`${basename}/access-denied`);
5961
}
60-
}, [canAccess, navigate, rest.isPending]);
62+
}, [basename, canAccess, navigate, rest.isPending]);
6163

6264
useEffect(() => {
6365
if (error) {
64-
navigate('/authentication-error');
66+
navigate(`${basename}/authentication-error`);
6567
}
66-
}, [navigate, error]);
68+
}, [basename, navigate, error]);
6769

6870
return rest;
6971
};

packages/ra-ui-materialui/src/layout/ResourceMenuItem.spec.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,25 @@ describe('ResourceMenuItem', () => {
1919
await screen.findByText('resources.posts.name');
2020
expect(screen.queryByText('resources.users.name')).toBeNull();
2121
});
22+
it('should not render when authProvider.canAccess throws', async () => {
23+
render(
24+
<AccessControl
25+
authProvider={
26+
{
27+
checkAuth: () => Promise.resolve(),
28+
canAccess: ({ resource }) =>
29+
resource === 'posts'
30+
? Promise.resolve(true)
31+
: Promise.reject(
32+
new Error('access control error')
33+
),
34+
} as any
35+
}
36+
/>
37+
);
38+
await screen.findByText('resources.posts.name');
39+
expect(screen.queryByText('resources.users.name')).toBeNull();
40+
});
2241
it('should not render when authProvider.canAccess returns false with a Function as <Admin> child', async () => {
2342
render(<AccessControlInsideAdminChildFunction />);
2443
await screen.findByText('resources.posts.name');

0 commit comments

Comments
 (0)