From b5246b29dfb5fe104674dac83996dada8031852a Mon Sep 17 00:00:00 2001 From: "Hugh A. Miles II" Date: Sat, 23 Oct 2021 03:47:56 -0400 Subject: [PATCH] feat: Hide nav create with RBAC (#17157) * recommit * Update MenuRight.tsx --- .../src/components/Menu/Menu.test.tsx | 52 ++++ .../src/components/Menu/MenuRight.tsx | 271 ++++++++++-------- superset-frontend/src/views/menu.tsx | 7 +- 3 files changed, 204 insertions(+), 126 deletions(-) diff --git a/superset-frontend/src/components/Menu/Menu.test.tsx b/superset-frontend/src/components/Menu/Menu.test.tsx index f3a2fd1ff5..8dd8f56b89 100644 --- a/superset-frontend/src/components/Menu/Menu.test.tsx +++ b/superset-frontend/src/components/Menu/Menu.test.tsx @@ -17,12 +17,32 @@ * under the License. */ import React from 'react'; +import * as reactRedux from 'react-redux'; import { render, screen } from 'spec/helpers/testing-library'; import userEvent from '@testing-library/user-event'; import { Menu } from './Menu'; import { dropdownItems } from './MenuRight'; +const user = { + createdOn: '2021-04-27T18:12:38.952304', + email: 'admin', + firstName: 'admin', + isActive: true, + lastName: 'admin', + permissions: {}, + roles: { + Admin: [ + ['can_sqllab', 'Superset'], + ['can_write', 'Dashboard'], + ['can_write', 'Chart'], + ], + }, + userId: 1, + username: 'admin', +}; + const mockedProps = { + user, data: { menu: [ { @@ -136,17 +156,27 @@ const notanonProps = { }, }; +const useSelectorMock = jest.spyOn(reactRedux, 'useSelector'); + +beforeEach(() => { + // setup a DOM element as a render target + useSelectorMock.mockClear(); +}); + test('should render', () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); const { container } = render(); expect(container).toBeInTheDocument(); }); test('should render the navigation', () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); render(); expect(screen.getByRole('navigation')).toBeInTheDocument(); }); test('should render the brand', () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); const { data: { brand: { alt, icon }, @@ -158,6 +188,7 @@ test('should render the brand', () => { }); test('should render all the top navbar menu items', () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); const { data: { menu }, } = mockedProps; @@ -168,6 +199,7 @@ test('should render all the top navbar menu items', () => { }); test('should render the top navbar child menu items', async () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); const { data: { menu }, } = mockedProps; @@ -184,6 +216,7 @@ test('should render the top navbar child menu items', async () => { }); test('should render the dropdown items', async () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); render(); const dropdown = screen.getByTestId('new-dropdown-icon'); userEvent.hover(dropdown); @@ -211,12 +244,14 @@ test('should render the dropdown items', async () => { }); test('should render the Settings', async () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); render(); const settings = await screen.findByText('Settings'); expect(settings).toBeInTheDocument(); }); test('should render the Settings menu item', async () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); render(); userEvent.hover(screen.getByText('Settings')); const label = await screen.findByText('Security'); @@ -224,6 +259,7 @@ test('should render the Settings menu item', async () => { }); test('should render the Settings dropdown child menu items', async () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); const { data: { settings }, } = mockedProps; @@ -234,16 +270,19 @@ test('should render the Settings dropdown child menu items', async () => { }); test('should render the plus menu (+) when user is not anonymous', () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); render(); expect(screen.getByTestId('new-dropdown')).toBeInTheDocument(); }); test('should NOT render the plus menu (+) when user is anonymous', () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); render(); expect(screen.queryByTestId('new-dropdown')).not.toBeInTheDocument(); }); test('should render the user actions when user is not anonymous', async () => { + useSelectorMock.mockReturnValue({ roles: mockedProps.user.roles }); const { data: { navbar_right: { user_info_url, user_logout_url }, @@ -263,11 +302,13 @@ test('should render the user actions when user is not anonymous', async () => { }); test('should NOT render the user actions when user is anonymous', () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); render(); expect(screen.queryByText('User')).not.toBeInTheDocument(); }); test('should render the Profile link when available', async () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); const { data: { navbar_right: { user_profile_url }, @@ -282,6 +323,7 @@ test('should render the Profile link when available', async () => { }); test('should render the About section and version_string, sha or build_number when available', async () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); const { data: { navbar_right: { version_sha, version_string, build_number }, @@ -301,6 +343,7 @@ test('should render the About section and version_string, sha or build_number wh }); test('should render the Documentation link when available', async () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); const { data: { navbar_right: { documentation_url }, @@ -313,6 +356,7 @@ test('should render the Documentation link when available', async () => { }); test('should render the Bug Report link when available', async () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); const { data: { navbar_right: { bug_report_url }, @@ -325,6 +369,7 @@ test('should render the Bug Report link when available', async () => { }); test('should render the Login link when user is anonymous', () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); const { data: { navbar_right: { user_login_url }, @@ -337,6 +382,13 @@ test('should render the Login link when user is anonymous', () => { }); test('should render the Language Picker', () => { + useSelectorMock.mockReturnValue({ roles: user.roles }); render(); expect(screen.getByLabelText('Languages')).toBeInTheDocument(); }); + +test('should hide create button without proper roles', () => { + useSelectorMock.mockReturnValue({ roles: [] }); + render(); + expect(screen.queryByTestId('new-dropdown')).not.toBeInTheDocument(); +}); diff --git a/superset-frontend/src/components/Menu/MenuRight.tsx b/superset-frontend/src/components/Menu/MenuRight.tsx index 1626713f54..42bf57ead4 100644 --- a/superset-frontend/src/components/Menu/MenuRight.tsx +++ b/superset-frontend/src/components/Menu/MenuRight.tsx @@ -21,6 +21,9 @@ import { MainNav as Menu } from 'src/common/components'; import { t, styled, css, SupersetTheme } from '@superset-ui/core'; import { Link } from 'react-router-dom'; import Icons from 'src/components/Icons'; +import findPermission from 'src/dashboard/util/findPermission'; +import { useSelector } from 'react-redux'; +import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import LanguagePicker from './LanguagePicker'; import { NavBarProps, MenuObjectProps } from './Menu'; @@ -29,16 +32,22 @@ export const dropdownItems = [ label: t('SQL query'), url: '/superset/sqllab?new=true', icon: 'fa-fw fa-search', + perm: 'can_sqllab', + view: 'Superset', }, { label: t('Chart'), url: '/chart/add', icon: 'fa-fw fa-bar-chart', + perm: 'can_write', + view: 'Chart', }, { label: t('Dashboard'), url: '/dashboard/new', icon: 'fa-fw fa-dashboard', + perm: 'can_write', + view: 'Dashboard', }, ]; @@ -83,134 +92,146 @@ const RightMenu = ({ settings, navbarRight, isFrontendRoute, -}: RightMenuProps) => ( - - - {!navbarRight.user_is_anonymous && ( - - } - icon={} - > - {dropdownItems.map(menu => ( - - - {' '} - {menu.label} - - - ))} - - )} - }> - {settings.map((section, index) => [ - - {section.childs?.map(child => { - if (typeof child !== 'string') { - return ( - - {isFrontendRoute(child.url) ? ( - {child.label} - ) : ( - {child.label} - )} - - ); - } - return null; - })} - , - index < settings.length - 1 && , - ])} +}: RightMenuProps) => { + const { roles } = useSelector( + state => state.user, + ); - {!navbarRight.user_is_anonymous && [ - , - - {navbarRight.user_profile_url && ( - - {t('Profile')} - + // if user has any of these roles the dropdown will appear + const canSql = findPermission('can_sqllab', 'Superset', roles); + const canDashboard = findPermission('can_write', 'Dashboard', roles); + const canChart = findPermission('can_write', 'Chart', roles); + const showActionDropdown = canSql || canChart || canDashboard; + return ( + + + {!navbarRight.user_is_anonymous && showActionDropdown && ( + + } + icon={} + > + {dropdownItems.map( + menu => + findPermission(menu.perm, menu.view, roles) && ( + + + {' '} + {menu.label} + + + ), )} - {navbarRight.user_info_url && ( - - {t('Info')} + + )} + }> + {settings.map((section, index) => [ + + {section.childs?.map(child => { + if (typeof child !== 'string') { + return ( + + {isFrontendRoute(child.url) ? ( + {child.label} + ) : ( + {child.label} + )} + + ); + } + return null; + })} + , + index < settings.length - 1 && , + ])} + + {!navbarRight.user_is_anonymous && [ + , + + {navbarRight.user_profile_url && ( + + {t('Profile')} + + )} + {navbarRight.user_info_url && ( + + {t('Info')} + + )} + + {t('Logout')} - )} - - {t('Logout')} - - , - ]} - {(navbarRight.version_string || - navbarRight.version_sha || - navbarRight.build_number) && [ - , - -
- {navbarRight.show_watermark && ( -
- {t('Powered by Apache Superset')} -
- )} - {navbarRight.version_string && ( -
- Version: {navbarRight.version_string} -
- )} - {navbarRight.version_sha && ( -
- SHA: {navbarRight.version_sha} -
- )} - {navbarRight.build_number && ( -
- Build: {navbarRight.build_number} -
- )} -
-
, - ]} -
- {navbarRight.show_language_picker && ( - + , + ]} + {(navbarRight.version_string || navbarRight.version_sha) && [ + , + +
+ {navbarRight.show_watermark && ( +
+ {t('Powered by Apache Superset')} +
+ )} + {navbarRight.version_string && ( +
+ Version: {navbarRight.version_string} +
+ )} + {navbarRight.version_sha && ( +
+ SHA: {navbarRight.version_sha} +
+ )} + {navbarRight.build_number && ( +
+ Build: {navbarRight.build_number} +
+ )} +
+
, + ]} + + {navbarRight.show_language_picker && ( + + )} +
+ {navbarRight.documentation_url && ( + + +   + )} -
- {navbarRight.documentation_url && ( - - -   - - )} - {navbarRight.bug_report_url && ( - - - - )} - {navbarRight.user_is_anonymous && ( - - - {t('Login')} - - )} -
-); + {navbarRight.bug_report_url && ( + + + + )} + {navbarRight.user_is_anonymous && ( + + + {t('Login')} + + )} + + ); +}; export default RightMenu; diff --git a/superset-frontend/src/views/menu.tsx b/superset-frontend/src/views/menu.tsx index 35f62526d5..68b72d70ec 100644 --- a/superset-frontend/src/views/menu.tsx +++ b/superset-frontend/src/views/menu.tsx @@ -27,6 +27,9 @@ import { ThemeProvider } from '@superset-ui/core'; import Menu from 'src/components/Menu/Menu'; import { theme } from 'src/preamble'; +import { Provider } from 'react-redux'; +import { store } from './store'; + const container = document.getElementById('app'); const bootstrapJson = container?.getAttribute('data-bootstrap') ?? '{}'; const bootstrap = JSON.parse(bootstrapJson); @@ -40,7 +43,9 @@ const app = ( // @ts-ignore: emotion types defs are incompatible between core and cache - + + + );