diff --git a/UPDATING.md b/UPDATING.md index 7fd97a4dab0f6f6bf7acb45df31426660fbfe9a7..077f43cdd8992d3e0876af4ee039b6623b3e53e8 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -46,6 +46,7 @@ assists people when migrating to a new version. - [30284](https://github.com/apache/superset/pull/30284) Deprecated GLOBAL_ASYNC_QUERIES_REDIS_CONFIG in favor of the new GLOBAL_ASYNC_QUERIES_CACHE_BACKEND configuration. To leverage Redis Sentinel, set CACHE_TYPE to RedisSentinelCache, or use RedisCache for standalone Redis - [31961](https://github.com/apache/superset/pull/31961) Upgraded React from version 16.13.1 to 17.0.2. If you are using custom frontend extensions or plugins, you may need to update them to be compatible with React 17. - [31260](https://github.com/apache/superset/pull/31260) Docker images now use `uv pip install` instead of `pip install` to manage the python envrionment. Most docker-based deployments will be affected, whether you derive one of the published images, or have custom bootstrap script that install python libraries (drivers) +- [32432](https://github.com/apache/superset/pull/31260) Moves the List Roles FAB view to the frontend and requires `FAB_ADD_SECURITY_API` to be enabled in the configuration and `superset init` to be executed. ### Potential Downtime diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/drilltodetail.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/drilltodetail.test.ts index f6fd4b6a5c2e6842eef4a3c758d3bc4f78e35072..eda6e56c452e698c9f06e78427e74c4d7dd024b8 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/drilltodetail.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/drilltodetail.test.ts @@ -434,7 +434,7 @@ describe('Drill to detail modal', () => { SUPPORTED_TIER2_CHARTS.forEach(waitForChartLoad); }); - describe.only('Modal actions', () => { + describe('Modal actions', () => { it('clears filters', () => { interceptSamples(); diff --git a/superset-frontend/src/components/ListView/Filters/Select.tsx b/superset-frontend/src/components/ListView/Filters/Select.tsx index edbc5ad452b59231d00adabf22601d4e20f07410..02cd898975938118d6c17d02e2af1ff85296cb76 100644 --- a/superset-frontend/src/components/ListView/Filters/Select.tsx +++ b/superset-frontend/src/components/ListView/Filters/Select.tsx @@ -37,6 +37,7 @@ interface SelectFilterProps extends BaseFilter { onSelect: (selected: SelectOption | undefined, isClear?: boolean) => void; paginate?: boolean; selects: Filter['selects']; + loading?: boolean; } function SelectFilter( @@ -47,6 +48,7 @@ function SelectFilter( initialValue, onSelect, selects = [], + loading = false, }: SelectFilterProps, ref: RefObject<FilterHandler>, ) { @@ -115,6 +117,7 @@ function SelectFilter( placeholder={t('Select or type a value')} showSearch value={selectedOption} + loading={loading} /> )} </FilterContainer> diff --git a/superset-frontend/src/components/ListView/Filters/index.tsx b/superset-frontend/src/components/ListView/Filters/index.tsx index 5733151180ea776fb17dd7a047e1d445fbb053e8..8f64b67d051f6dc6f43892a353f7dd2852b12212 100644 --- a/superset-frontend/src/components/ListView/Filters/index.tsx +++ b/superset-frontend/src/components/ListView/Filters/index.tsx @@ -75,6 +75,7 @@ function UIFilters( selects, toolTipDescription, onFilterUpdate, + loading, }, index, ) => { @@ -103,6 +104,7 @@ function UIFilters( }} paginate={paginate} selects={selects} + loading={loading ?? false} /> ); } diff --git a/superset-frontend/src/components/ListView/types.ts b/superset-frontend/src/components/ListView/types.ts index 90de4eab18e765266186e7cc7c7e268e9479cf24..5b498070acffbd131467ee1d5f0bb8480083b796 100644 --- a/superset-frontend/src/components/ListView/types.ts +++ b/superset-frontend/src/components/ListView/types.ts @@ -59,6 +59,7 @@ export interface Filter { pageSize: number, ) => Promise<{ data: SelectOption[]; totalCount: number }>; paginate?: boolean; + loading?: boolean; } export type Filters = Filter[]; diff --git a/superset-frontend/src/features/home/SubMenu.tsx b/superset-frontend/src/features/home/SubMenu.tsx index 7945e76da9b9e5a8ca97588dfaa152f7a727326f..dd03fba9d1326dca5c3fa074159c4faecd7e270b 100644 --- a/superset-frontend/src/features/home/SubMenu.tsx +++ b/superset-frontend/src/features/home/SubMenu.tsx @@ -134,6 +134,7 @@ export interface ButtonProps { | 'warning' | 'success' | 'tertiary'; + loading?: boolean; } export interface SubMenuProps { @@ -282,6 +283,7 @@ const SubMenuComponent: FunctionComponent<SubMenuProps> = props => { buttonStyle={btn.buttonStyle} onClick={btn.onClick} data-test={btn['data-test']} + loading={btn.loading ?? false} > {btn.name} </Button> diff --git a/superset-frontend/src/features/roles/RoleFormItems.tsx b/superset-frontend/src/features/roles/RoleFormItems.tsx new file mode 100644 index 0000000000000000000000000000000000000000..56c4ad76f4bf6a1f110b23d7721af045b8432bc6 --- /dev/null +++ b/superset-frontend/src/features/roles/RoleFormItems.tsx @@ -0,0 +1,70 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { FormItem } from 'src/components/Form'; +import Select from 'src/components/Select/Select'; +import { Input } from 'src/components/Input'; +import { t } from '@superset-ui/core'; +import { FC } from 'react'; +import { FormattedPermission, UserObject } from './types'; + +interface PermissionsFieldProps { + permissions: FormattedPermission[]; +} + +interface UsersFieldProps { + users: UserObject[]; +} + +export const RoleNameField = () => ( + <FormItem + name="roleName" + label={t('Role Name')} + rules={[{ required: true, message: t('Role name is required') }]} + > + <Input name="roleName" data-test="role-name-input" /> + </FormItem> +); + +export const PermissionsField: FC<PermissionsFieldProps> = ({ + permissions, +}) => ( + <FormItem name="rolePermissions" label={t('Permissions')}> + <Select + mode="multiple" + name="rolePermissions" + options={permissions.map(permission => ({ + label: permission.label, + value: permission.id, + }))} + getPopupContainer={trigger => trigger.closest('.antd5-modal-content')} + data-test="permissions-select" + /> + </FormItem> +); + +export const UsersField: FC<UsersFieldProps> = ({ users }) => ( + <FormItem name="roleUsers" label={t('Users')}> + <Select + mode="multiple" + name="roleUsers" + options={users.map(user => ({ label: user.username, value: user.id }))} + data-test="users-select" + /> + </FormItem> +); diff --git a/superset-frontend/src/features/roles/RoleListAddModal.test.tsx b/superset-frontend/src/features/roles/RoleListAddModal.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..69d7eb66365c159e3780f2249454f715735169df --- /dev/null +++ b/superset-frontend/src/features/roles/RoleListAddModal.test.tsx @@ -0,0 +1,92 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + render, + screen, + fireEvent, + waitFor, +} from 'spec/helpers/testing-library'; +import RoleListAddModal from './RoleListAddModal'; +import { createRole } from './utils'; + +const mockToasts = { + addDangerToast: jest.fn(), + addSuccessToast: jest.fn(), +}; + +jest.mock('./utils'); +const mockCreateRole = jest.mocked(createRole); + +jest.mock('src/components/MessageToasts/withToasts', () => ({ + useToasts: () => mockToasts, +})); + +describe('RoleListAddModal', () => { + const mockProps = { + show: true, + onHide: jest.fn(), + onSave: jest.fn(), + permissions: [ + { id: 1, label: 'Permission 1', value: 'Permission_1' }, + { id: 2, label: 'Permission 2', value: 'Permission_2' }, + ], + }; + + it('renders modal with form fields', () => { + render(<RoleListAddModal {...mockProps} />); + expect(screen.getByText('Add Role')).toBeInTheDocument(); + expect(screen.getByText('Role Name')).toBeInTheDocument(); + expect(screen.getByText('Permissions')).toBeInTheDocument(); + }); + + it('calls onHide when cancel button is clicked', () => { + render(<RoleListAddModal {...mockProps} />); + fireEvent.click(screen.getByTestId('modal-cancel-button')); + expect(mockProps.onHide).toHaveBeenCalled(); + }); + + it('disables save button when role name is empty', () => { + render(<RoleListAddModal {...mockProps} />); + expect(screen.getByTestId('form-modal-save-button')).toBeDisabled(); + }); + + it('enables save button when role name is entered', () => { + render(<RoleListAddModal {...mockProps} />); + fireEvent.change(screen.getByTestId('role-name-input'), { + target: { value: 'New Role' }, + }); + expect(screen.getByTestId('form-modal-save-button')).toBeEnabled(); + }); + + it('calls createRole when save button is clicked', async () => { + render(<RoleListAddModal {...mockProps} />); + + fireEvent.change(screen.getByTestId('role-name-input'), { + target: { value: 'New Role' }, + }); + + const saveButton = screen.getByRole('button', { name: 'Save' }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockCreateRole).toHaveBeenCalledWith('New Role'); + }); + }); +}); diff --git a/superset-frontend/src/features/roles/RoleListAddModal.tsx b/superset-frontend/src/features/roles/RoleListAddModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c80def6724a164efcd9e8ce13c823eab5d968c34 --- /dev/null +++ b/superset-frontend/src/features/roles/RoleListAddModal.tsx @@ -0,0 +1,71 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { t } from '@superset-ui/core'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import FormModal from 'src/components/Modal/FormModal'; +import { createRole, updateRolePermissions } from './utils'; +import { PermissionsField, RoleNameField } from './RoleFormItems'; +import { BaseModalProps, FormattedPermission, RoleForm } from './types'; + +export interface RoleListAddModalProps extends BaseModalProps { + permissions: FormattedPermission[]; +} + +function RoleListAddModal({ + show, + onHide, + onSave, + permissions, +}: RoleListAddModalProps) { + const { addDangerToast, addSuccessToast } = useToasts(); + + const handleFormSubmit = async (values: RoleForm) => { + try { + const { json: roleResponse } = await createRole(values.roleName); + + if (values.rolePermissions?.length > 0) { + await updateRolePermissions(roleResponse.id, values.rolePermissions); + } + + addSuccessToast(t('Role was successfully created!')); + } catch (err) { + addDangerToast(t('Error while adding role!')); + throw err; + } + }; + + return ( + <FormModal + show={show} + onHide={onHide} + title={t('Add Role')} + onSave={onSave} + formSubmitHandler={handleFormSubmit} + requiredFields={['roleName']} + initialValues={{}} + > + <> + <RoleNameField /> + <PermissionsField permissions={permissions} /> + </> + </FormModal> + ); +} + +export default RoleListAddModal; diff --git a/superset-frontend/src/features/roles/RoleListDuplicateModal.test.tsx b/superset-frontend/src/features/roles/RoleListDuplicateModal.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..21c56b9b30999301ba61d37f7a2d7c0d4e06ef54 --- /dev/null +++ b/superset-frontend/src/features/roles/RoleListDuplicateModal.test.tsx @@ -0,0 +1,100 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + render, + screen, + fireEvent, + waitFor, +} from 'spec/helpers/testing-library'; +import RoleListDuplicateModal from './RoleListDuplicateModal'; +import { createRole, updateRolePermissions } from './utils'; + +const mockToasts = { + addDangerToast: jest.fn(), + addSuccessToast: jest.fn(), +}; + +jest.mock('./utils'); +const mockCreateRole = jest.mocked(createRole); +const mockUpdateRolePermissions = jest.mocked(updateRolePermissions); + +jest.mock('src/components/MessageToasts/withToasts', () => ({ + useToasts: () => mockToasts, +})); + +describe('RoleListDuplicateModal', () => { + const mockRole = { + id: 1, + name: 'Admin', + permission_ids: [10, 20], + user_ids: [1], + }; + + const mockProps = { + role: mockRole, + show: true, + onHide: jest.fn(), + onSave: jest.fn(), + }; + + it('renders modal with form fields', () => { + render(<RoleListDuplicateModal {...mockProps} />); + expect( + screen.getByText(`Duplicate role ${mockRole.name}`), + ).toBeInTheDocument(); + expect(screen.getByText('Role Name')).toBeInTheDocument(); + }); + + it('calls onHide when cancel button is clicked', () => { + render(<RoleListDuplicateModal {...mockProps} />); + fireEvent.click(screen.getByTestId('modal-cancel-button')); + expect(mockProps.onHide).toHaveBeenCalled(); + }); + + it('disables save button when role name is empty', () => { + render(<RoleListDuplicateModal {...mockProps} />); + expect(screen.getByTestId('form-modal-save-button')).toBeDisabled(); + }); + + it('enables save button when role name is entered', () => { + render(<RoleListDuplicateModal {...mockProps} />); + fireEvent.change(screen.getByTestId('role-name-input'), { + target: { value: 'New Role' }, + }); + expect(screen.getByTestId('form-modal-save-button')).toBeEnabled(); + }); + + it('calls createRole when save button is clicked', async () => { + mockCreateRole.mockResolvedValue({ json: { id: 2 } } as any); + + render(<RoleListDuplicateModal {...mockProps} />); + + fireEvent.change(screen.getByTestId('role-name-input'), { + target: { value: 'New Role' }, + }); + + fireEvent.click(screen.getByTestId('form-modal-save-button')); + + await waitFor(() => { + expect(mockCreateRole).toHaveBeenCalledWith('New Role'); + expect(mockUpdateRolePermissions).toHaveBeenCalledWith(2, [10, 20]); + }); + }); +}); diff --git a/superset-frontend/src/features/roles/RoleListDuplicateModal.tsx b/superset-frontend/src/features/roles/RoleListDuplicateModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f7d6b7a19d03fa10762de8b0a8d464527b850ca5 --- /dev/null +++ b/superset-frontend/src/features/roles/RoleListDuplicateModal.tsx @@ -0,0 +1,69 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { t } from '@superset-ui/core'; +import { RoleObject } from 'src/pages/RolesList'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import FormModal from 'src/components/Modal/FormModal'; +import { RoleNameField } from './RoleFormItems'; +import { BaseModalProps, RoleForm } from './types'; +import { createRole, updateRolePermissions } from './utils'; + +export interface RoleListDuplicateModalProps extends BaseModalProps { + role: RoleObject; +} + +function RoleListDuplicateModal({ + role, + show, + onHide, + onSave, +}: RoleListDuplicateModalProps) { + const { name, permission_ids } = role; + const { addDangerToast, addSuccessToast } = useToasts(); + + const handleFormSubmit = async (values: RoleForm) => { + try { + const { json: roleResponse } = await createRole(values.roleName); + + if (permission_ids.length > 0) { + await updateRolePermissions(roleResponse.id, permission_ids); + } + addSuccessToast(t('Role was successfully duplicated!')); + } catch (err) { + addDangerToast(t('Error while duplicating role!')); + throw err; + } + }; + + return ( + <FormModal + show={show} + onHide={onHide} + title={t('Duplicate role %(name)s', { name })} + onSave={onSave} + formSubmitHandler={handleFormSubmit} + requiredFields={['roleName']} + initialValues={{}} + > + <RoleNameField /> + </FormModal> + ); +} +export default RoleListDuplicateModal; diff --git a/superset-frontend/src/features/roles/RoleListEditModal.test.tsx b/superset-frontend/src/features/roles/RoleListEditModal.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8e8c17dc0a04a9409f2f2ef471b7d41012cc0b4f --- /dev/null +++ b/superset-frontend/src/features/roles/RoleListEditModal.test.tsx @@ -0,0 +1,153 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + render, + screen, + fireEvent, + waitFor, +} from 'spec/helpers/testing-library'; +import RoleListEditModal from './RoleListEditModal'; +import { + updateRoleName, + updateRolePermissions, + updateRoleUsers, +} from './utils'; + +const mockToasts = { + addDangerToast: jest.fn(), + addSuccessToast: jest.fn(), +}; + +jest.mock('./utils'); +const mockUpdateRoleName = jest.mocked(updateRoleName); +const mockUpdateRolePermissions = jest.mocked(updateRolePermissions); +const mockUpdateRoleUsers = jest.mocked(updateRoleUsers); + +jest.mock('src/components/MessageToasts/withToasts', () => ({ + useToasts: () => mockToasts, +})); + +describe('RoleListEditModal', () => { + const mockRole = { + id: 1, + name: 'Admin', + permission_ids: [10, 20], + user_ids: [5, 7], + }; + + const mockPermissions = [ + { id: 10, label: 'Permission A', value: 'perm_a' }, + { id: 20, label: 'Permission B', value: 'perm_b' }, + ]; + + const mockUsers = [ + { + id: 5, + firstName: 'John', + lastName: 'Doe', + username: 'johndoe', + email: 'john@example.com', + isActive: true, + roles: [ + { + id: 1, + name: 'Admin', + }, + ], + }, + ]; + + const mockProps = { + role: mockRole, + show: true, + onHide: jest.fn(), + onSave: jest.fn(), + permissions: mockPermissions, + users: mockUsers, + }; + + it('renders modal with correct title and fields', () => { + render(<RoleListEditModal {...mockProps} />); + expect(screen.getAllByText('Edit Role')[0]).toBeInTheDocument(); + expect(screen.getByText('Role Name')).toBeInTheDocument(); + expect(screen.getByText('Permissions')).toBeInTheDocument(); + expect(screen.getAllByText('Users')[0]).toBeInTheDocument(); + }); + + it('calls onHide when cancel button is clicked', () => { + render(<RoleListEditModal {...mockProps} />); + fireEvent.click(screen.getByTestId('modal-cancel-button')); + expect(mockProps.onHide).toHaveBeenCalled(); + }); + + it('disables save button when role name is empty', () => { + render(<RoleListEditModal {...mockProps} />); + fireEvent.change(screen.getByTestId('role-name-input'), { + target: { value: '' }, + }); + expect(screen.getByTestId('form-modal-save-button')).toBeDisabled(); + }); + + it('enables save button when role name is entered', () => { + render(<RoleListEditModal {...mockProps} />); + fireEvent.change(screen.getByTestId('role-name-input'), { + target: { value: 'Updated Role' }, + }); + expect(screen.getByTestId('form-modal-save-button')).toBeEnabled(); + }); + + it('calls update functions when save button is clicked', async () => { + render(<RoleListEditModal {...mockProps} />); + + fireEvent.change(screen.getByTestId('role-name-input'), { + target: { value: 'Updated Role' }, + }); + + fireEvent.click(screen.getByTestId('form-modal-save-button')); + + await waitFor(() => { + expect(mockUpdateRoleName).toHaveBeenCalledWith( + mockRole.id, + 'Updated Role', + ); + expect(mockUpdateRolePermissions).toHaveBeenCalledWith( + mockRole.id, + mockRole.permission_ids, + ); + expect(mockUpdateRoleUsers).toHaveBeenCalledWith( + mockRole.id, + mockRole.user_ids, + ); + expect(mockProps.onSave).toHaveBeenCalled(); + }); + }); + + it('switches tabs correctly', () => { + render(<RoleListEditModal {...mockProps} />); + + const usersTab = screen.getByRole('tab', { name: 'Users' }); + fireEvent.click(usersTab); + + expect(screen.getByText('First Name')).toBeInTheDocument(); + expect(screen.getByText('Last Name')).toBeInTheDocument(); + expect(screen.getByText('User Name')).toBeInTheDocument(); + expect(screen.getByText('Email')).toBeInTheDocument(); + }); +}); diff --git a/superset-frontend/src/features/roles/RoleListEditModal.tsx b/superset-frontend/src/features/roles/RoleListEditModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e22f950d5e2a5eef507734e0575997b37e51f812 --- /dev/null +++ b/superset-frontend/src/features/roles/RoleListEditModal.tsx @@ -0,0 +1,153 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState } from 'react'; +import { t } from '@superset-ui/core'; +import Tabs from 'src/components/Tabs'; +import { RoleObject } from 'src/pages/RolesList'; +import TableView, { EmptyWrapperType } from 'src/components/TableView'; +import { + BaseModalProps, + FormattedPermission, + RoleForm, + UserObject, +} from 'src/features/roles/types'; +import { CellProps } from 'react-table'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import FormModal from 'src/components/Modal/FormModal'; +import { PermissionsField, RoleNameField, UsersField } from './RoleFormItems'; +import { + updateRoleName, + updateRolePermissions, + updateRoleUsers, +} from './utils'; + +export interface RoleListEditModalProps extends BaseModalProps { + role: RoleObject; + permissions: FormattedPermission[]; + users: UserObject[]; +} + +const roleTabs = { + edit: { + key: 'edit', + name: t('Edit Role'), + }, + users: { + key: 'users', + name: t('Users'), + }, +}; + +const userColumns = [ + { + accessor: 'first_name', + Header: 'First Name', + }, + { + accessor: 'last_name', + Header: 'Last Name', + }, + { + accessor: 'username', + Header: 'User Name', + }, + { + accessor: 'email', + Header: 'Email', + }, + { + accessor: 'active', + Header: 'Is Active?', + Cell: ({ cell }: CellProps<{ active: boolean }>) => + cell.value ? 'Yes' : 'No', + }, +]; + +function RoleListEditModal({ + show, + onHide, + role, + onSave, + permissions, + users, +}: RoleListEditModalProps) { + const { id, name, permission_ids, user_ids } = role; + const [activeTabKey, setActiveTabKey] = useState(roleTabs.edit.key); + const { addDangerToast, addSuccessToast } = useToasts(); + const filteredUsers = users.filter(user => + user?.roles.some(role => role.id === id), + ); + + const handleFormSubmit = async (values: RoleForm) => { + try { + await updateRoleName(id, values.roleName); + await updateRolePermissions(id, values.rolePermissions); + await updateRoleUsers(id, values.roleUsers); + addSuccessToast(t('Role successfully updated!')); + } catch (err) { + addDangerToast(t('Error while updating role!')); + throw err; + } + }; + + const initialValues = { + roleName: name, + rolePermissions: permission_ids, + roleUsers: user_ids, + }; + + return ( + <FormModal + show={show} + onHide={onHide} + title={t('Edit Role')} + onSave={onSave} + formSubmitHandler={handleFormSubmit} + initialValues={initialValues} + bodyStyle={{ height: '400px' }} + requiredFields={['roleName']} + > + <Tabs + activeKey={activeTabKey} + onChange={activeKey => setActiveTabKey(activeKey)} + > + <Tabs.TabPane + tab={roleTabs.edit.name} + key={roleTabs.edit.key} + forceRender + > + <> + <RoleNameField /> + <PermissionsField permissions={permissions} /> + <UsersField users={users} /> + </> + </Tabs.TabPane> + <Tabs.TabPane tab={roleTabs.users.name} key={roleTabs.users.key}> + <TableView + columns={userColumns} + data={filteredUsers} + emptyWrapperType={EmptyWrapperType.Small} + /> + </Tabs.TabPane> + </Tabs> + </FormModal> + ); +} + +export default RoleListEditModal; diff --git a/superset-frontend/src/features/roles/types.ts b/superset-frontend/src/features/roles/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..d97a8dad611bcd11e50d8f4f3275d9d663cee745 --- /dev/null +++ b/superset-frontend/src/features/roles/types.ts @@ -0,0 +1,66 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export type PermissionView = { + name: string; +}; + +export type PermissionResource = { + id: number; + permission: PermissionView; + view_menu: PermissionView; +}; + +export type FormattedPermission = { + label: string; + value: string; + id: number; +}; + +export type RolePermissions = { + id: number; + permission_name: string; + view_menu_name: string; +}; + +export type UserObject = { + id: number; + firstName: string; + lastName: string; + username: string; + email: string; + isActive: boolean; + roles: Array<RoleInfo>; +}; + +export type RoleInfo = { + id: number; + name: string; +}; + +export type RoleForm = { + roleName: string; + rolePermissions: number[]; + roleUsers: number[]; +}; + +export interface BaseModalProps { + show: boolean; + onHide: () => void; + onSave: () => void; +} diff --git a/superset-frontend/src/features/roles/utils.ts b/superset-frontend/src/features/roles/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..fcee8e6463330ae361320e00e348508607d7238d --- /dev/null +++ b/superset-frontend/src/features/roles/utils.ts @@ -0,0 +1,47 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SupersetClient } from '@superset-ui/core'; + +export const createRole = async (name: string) => + SupersetClient.post({ + endpoint: '/api/v1/security/roles/', + jsonPayload: { name }, + }); + +export const updateRolePermissions = async ( + roleId: number, + permissionIds: number[], +) => + SupersetClient.post({ + endpoint: `/api/v1/security/roles/${roleId}/permissions`, + jsonPayload: { permission_view_menu_ids: permissionIds }, + }); + +export const updateRoleUsers = async (roleId: number, userIds: number[]) => + SupersetClient.put({ + endpoint: `/api/v1/security/roles/${roleId}/users`, + jsonPayload: { user_ids: userIds }, + }); + +export const updateRoleName = async (roleId: number, name: string) => + SupersetClient.put({ + endpoint: `/api/v1/security/roles/${roleId}`, + jsonPayload: { name }, + }); diff --git a/superset-frontend/src/pages/RolesList/RolesList.test.tsx b/superset-frontend/src/pages/RolesList/RolesList.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c4c4ee25541b65682eaa0cf2c73e9b92f4aef331 --- /dev/null +++ b/superset-frontend/src/pages/RolesList/RolesList.test.tsx @@ -0,0 +1,206 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import fetchMock from 'fetch-mock'; +import configureStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { + render, + screen, + fireEvent, + waitFor, + act, + within, +} from 'spec/helpers/testing-library'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryParamProvider } from 'use-query-params'; +import RolesList from './index'; + +const mockStore = configureStore([thunk]); +const store = mockStore({}); + +const rolesEndpoint = 'glob:*/security/roles/search/?*'; +const roleEndpoint = 'glob:*/api/v1/security/roles/*'; +const permissionsEndpoint = 'glob:*/api/v1/security/permissions-resources/?*'; +const usersEndpoint = 'glob:*/api/v1/security/users/?*'; + +const mockRoles = [...new Array(3)].map((_, i) => ({ + id: i, + name: `role ${i}`, + user_ids: [i, i + 1], + permission_ids: [i, i + 1, i + 2], +})); + +const mockPermissions = [...new Array(10)].map((_, i) => ({ + id: i, + permission: { name: `permission_${i}` }, + view_menu: { name: `view_menu_${i}` }, +})); + +const mockUsers = [...new Array(5)].map((_, i) => ({ + id: i, + username: `user_${i}`, + first_name: `User`, + last_name: `${i}`, + roles: [ + { + id: 1, + }, + ], +})); + +const mockUser = { + userId: 1, + firstName: 'Admin', + lastName: 'User', + roles: [ + { + id: 1, + name: 'Admin', + }, + ], +}; + +jest.mock('src/dashboard/util/permissionUtils', () => ({ + ...jest.requireActual('src/dashboard/util/permissionUtils'), + isUserAdmin: jest.fn(() => true), +})); + +fetchMock.get(rolesEndpoint, { + ids: [2, 0, 1], + result: mockRoles, + count: 3, +}); + +fetchMock.get(permissionsEndpoint, { + count: mockPermissions.length, + result: mockPermissions, +}); + +fetchMock.get(usersEndpoint, { + count: mockUsers.length, + result: mockUsers, +}); + +fetchMock.delete(roleEndpoint, {}); +fetchMock.put(roleEndpoint, {}); + +describe('RolesList', () => { + async function renderAndWait() { + const mounted = act(async () => { + const mockedProps = {}; + render( + <MemoryRouter> + <QueryParamProvider> + <RolesList + user={mockUser} + addDangerToast={() => {}} + addSuccessToast={() => {}} + {...mockedProps} + /> + </QueryParamProvider> + </MemoryRouter>, + { useRedux: true, store }, + ); + }); + return mounted; + } + beforeEach(() => { + fetchMock.resetHistory(); + }); + + it('renders', async () => { + await renderAndWait(); + expect(await screen.findByText('List Roles')).toBeInTheDocument(); + }); + + it('fetches roles on load', async () => { + await renderAndWait(); + await waitFor(() => { + const calls = fetchMock.calls(rolesEndpoint); + expect(calls.length).toBeGreaterThan(0); + }); + }); + + it('fetches permissions and users on load', async () => { + await renderAndWait(); + await waitFor(() => { + const permissionCalls = fetchMock.calls(permissionsEndpoint); + const userCalls = fetchMock.calls(usersEndpoint); + expect(permissionCalls.length).toBeGreaterThan(0); + expect(userCalls.length).toBeGreaterThan(0); + }); + }); + + it('renders filters options', async () => { + await renderAndWait(); + + const typeFilter = screen.queryAllByTestId('filters-select'); + expect(typeFilter).toHaveLength(3); + }); + + it('renders correct list columns', async () => { + await renderAndWait(); + + const table = screen.getByRole('table'); + expect(table).toBeInTheDocument(); + + const nameColumn = await within(table).findByText('Name'); + const actionsColumn = await within(table).findByText('Actions'); + + expect(nameColumn).toBeInTheDocument(); + expect(actionsColumn).toBeInTheDocument(); + }); + + it('opens add modal when Add Role button is clicked', async () => { + await renderAndWait(); + + const addButton = screen.getByTestId('add-role-button'); + fireEvent.click(addButton); + + expect(screen.queryByTestId('Add Role-modal')).toBeInTheDocument(); + }); + + it('open duplicate modal when duplicate button is clicked', async () => { + await renderAndWait(); + + const table = screen.getByRole('table'); + expect(table).toBeInTheDocument(); + const duplicateAction = within(table).queryAllByTestId( + 'role-list-duplicate-action', + )[0]; + expect(duplicateAction).toBeInTheDocument(); + fireEvent.click(duplicateAction); + expect( + screen.queryByTestId('Duplicate role role 0-modal'), + ).toBeInTheDocument(); + }); + + it('open edit modal when edit button is clicked', async () => { + await renderAndWait(); + + const table = screen.getByRole('table'); + expect(table).toBeInTheDocument(); + const editAction = within(table).queryAllByTestId( + 'role-list-edit-action', + )[0]; + expect(editAction).toBeInTheDocument(); + fireEvent.click(editAction); + expect(screen.queryByTestId('Edit Role-modal')).toBeInTheDocument(); + }); +}); diff --git a/superset-frontend/src/pages/RolesList/index.tsx b/superset-frontend/src/pages/RolesList/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..be97427bd20ac4ccc4da1359a3fd2d801e5b8e16 --- /dev/null +++ b/superset-frontend/src/pages/RolesList/index.tsx @@ -0,0 +1,507 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { css, t, SupersetClient, useTheme } from '@superset-ui/core'; +import { useListViewResource } from 'src/views/CRUD/hooks'; +import RoleListAddModal from 'src/features/roles/RoleListAddModal'; +import RoleListEditModal from 'src/features/roles/RoleListEditModal'; +import RoleListDuplicateModal from 'src/features/roles/RoleListDuplicateModal'; +import withToasts from 'src/components/MessageToasts/withToasts'; +import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; +import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; +import ListView, { + ListViewProps, + Filters, + FilterOperator, +} from 'src/components/ListView'; +import DeleteModal from 'src/components/DeleteModal'; +import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; +import { + FormattedPermission, + PermissionResource, + UserObject, +} from 'src/features/roles/types'; +import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; +import { Icons } from 'src/components/Icons'; + +const PAGE_SIZE = 25; + +interface RolesListProps { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; + user: { + userId: string | number; + firstName: string; + lastName: string; + roles: object; + }; +} + +export type RoleObject = { + id: number; + name: string; + permission_ids: number[]; + users?: Array<UserObject>; + user_ids: number[]; +}; + +enum ModalType { + ADD = 'add', + EDIT = 'edit', + DUPLICATE = 'duplicate', +} + +function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) { + const theme = useTheme(); + const { + state: { + loading, + resourceCount: rolesCount, + resourceCollection: roles, + bulkSelectEnabled, + }, + fetchData, + refreshData, + toggleBulkSelect, + } = useListViewResource<RoleObject>( + 'security/roles/search', + t('Role'), + addDangerToast, + false, + ); + const [modalState, setModalState] = useState({ + edit: false, + add: false, + duplicate: false, + }); + const openModal = (type: ModalType) => + setModalState(prev => ({ ...prev, [type]: true })); + const closeModal = (type: ModalType) => + setModalState(prev => ({ ...prev, [type]: false })); + + const [currentRole, setCurrentRole] = useState<RoleObject | null>(null); + const [roleCurrentlyDeleting, setRoleCurrentlyDeleting] = + useState<RoleObject | null>(null); + const [permissions, setPermissions] = useState<FormattedPermission[]>([]); + const [users, setUsers] = useState<UserObject[]>([]); + const [loadingState, setLoadingState] = useState({ + permissions: true, + users: true, + }); + + const isAdmin = useMemo(() => isUserAdmin(user), [user]); + + const fetchPermissions = useCallback(async () => { + try { + const pageSize = 100; + + const fetchPage = async (pageIndex: number) => { + const response = await SupersetClient.get({ + endpoint: `api/v1/security/permissions-resources/?q={"page_size":${pageSize}, "page":${pageIndex}}`, + }); + + return { + count: response.json.count, + results: response.json.result.map( + ({ permission, view_menu, id }: PermissionResource) => ({ + label: `${permission.name.replace(/_/g, ' ')} ${view_menu.name.replace(/_/g, ' ')}`, + value: `${permission.name}__${view_menu.name}`, + id, + }), + ), + }; + }; + + const initialResponse = await fetchPage(0); + const totalPermissions = initialResponse.count; + const firstPageResults = initialResponse.results; + + if (firstPageResults.length >= totalPermissions) { + setPermissions(firstPageResults); + return; + } + + const totalPages = Math.ceil(totalPermissions / pageSize); + + const permissionRequests = Array.from( + { length: totalPages - 1 }, + (_, i) => fetchPage(i + 1), + ); + const remainingResults = await Promise.all(permissionRequests); + + setPermissions([ + ...firstPageResults, + ...remainingResults.flatMap(res => res.results), + ]); + } catch (err) { + addDangerToast(t('Error while fetching permissions')); + } finally { + setLoadingState(prev => ({ ...prev, permissions: false })); + } + }, []); + + const fetchUsers = useCallback(async () => { + try { + const pageSize = 100; + + const fetchPage = async (pageIndex: number) => { + const response = await SupersetClient.get({ + endpoint: `api/v1/security/users/?q={"page_size":${pageSize},"page":${pageIndex}}`, + }); + return response.json; + }; + + const initialResponse = await fetchPage(0); + const totalUsers = initialResponse.count; + const firstPageResults = initialResponse.result; + + if (pageSize >= totalUsers) { + setUsers(firstPageResults); + return; + } + + const totalPages = Math.ceil(totalUsers / pageSize); + + const userRequests = Array.from({ length: totalPages - 1 }, (_, i) => + fetchPage(i + 1), + ); + const remainingResults = await Promise.all(userRequests); + + setUsers([ + ...firstPageResults, + ...remainingResults.flatMap(res => res.result), + ]); + } catch (err) { + addDangerToast(t('Error while fetching users')); + } finally { + setLoadingState(prev => ({ ...prev, users: false })); + } + }, []); + + useEffect(() => { + fetchPermissions(); + }, [fetchPermissions]); + + useEffect(() => { + fetchUsers(); + }, [fetchUsers]); + + const handleRoleDelete = async ({ id, name }: RoleObject) => { + try { + await SupersetClient.delete({ + endpoint: `/api/v1/security/roles/${id}`, + }); + + refreshData(); + setRoleCurrentlyDeleting(null); + addSuccessToast(t('Deleted role: %s', name)); + } catch (error) { + addDangerToast(t('There was an issue deleting %s', name)); + } + }; + + const handleBulkRolesDelete = async (rolesToDelete: RoleObject[]) => { + const deletedRoleNames: string[] = []; + + await Promise.all( + rolesToDelete.map(async role => { + try { + await SupersetClient.delete({ + endpoint: `api/v1/security/roles/${role.id}`, + }); + + deletedRoleNames.push(role.name); + } catch (error) { + addDangerToast(t('Error deleting %s', role.name)); + } + }), + ); + + if (deletedRoleNames.length > 0) { + addSuccessToast(t('Deleted roles: %s', deletedRoleNames.join(', '))); + } + + refreshData(); + }; + + const initialSort = [{ id: 'name', desc: true }]; + const columns = useMemo( + () => [ + { + accessor: 'name', + Header: t('Name'), + Cell: ({ + row: { + original: { name }, + }, + }: any) => <span>{name}</span>, + }, + { + accessor: 'user_ids', + Header: t('Users'), + hidden: true, + Cell: ({ row: { original } }: any) => original.user_ids.join(', '), + }, + { + accessor: 'permission_ids', + Header: t('Permissions'), + hidden: true, + Cell: ({ row: { original } }: any) => + original.permission_ids.join(', '), + }, + { + Cell: ({ row: { original } }: any) => { + const handleEdit = () => { + setCurrentRole(original); + openModal(ModalType.EDIT); + }; + const handleDelete = () => setRoleCurrentlyDeleting(original); + const handleDuplicate = () => { + setCurrentRole(original); + openModal(ModalType.DUPLICATE); + }; + + const actions = isAdmin + ? [ + { + label: 'role-list-edit-action', + tooltip: t('Edit role'), + placement: 'bottom', + icon: 'EditOutlined', + onClick: handleEdit, + }, + { + label: 'role-list-duplicate-action', + tooltip: t('Duplicate role'), + placement: 'bottom', + icon: 'CopyOutlined', + onClick: handleDuplicate, + }, + { + label: 'role-list-delete-action', + tooltip: t('Delete role'), + placement: 'bottom', + icon: 'DeleteOutlined', + onClick: handleDelete, + }, + ] + : []; + + return <ActionsBar actions={actions as ActionProps[]} />; + }, + Header: t('Actions'), + id: 'actions', + disableSortBy: true, + hidden: !isAdmin, + size: 'xl', + }, + ], + [isAdmin], + ); + + const subMenuButtons: SubMenuProps['buttons'] = []; + + if (isAdmin) { + subMenuButtons.push( + { + name: ( + <> + <Icons.PlusOutlined + iconColor={theme.colors.primary.light5} + iconSize="m" + css={css` + margin: auto ${theme.gridUnit * 2}px auto 0; + vertical-align: text-top; + `} + /> + {t('Role')} + </> + ), + buttonStyle: 'primary', + onClick: () => { + openModal(ModalType.ADD); + }, + loading: loadingState.permissions, + 'data-test': 'add-role-button', + }, + { + name: t('Bulk select'), + onClick: toggleBulkSelect, + buttonStyle: 'secondary', + }, + ); + } + + const filters: Filters = useMemo( + () => [ + { + Header: t('Name'), + key: 'name', + id: 'name', + input: 'search', + operator: FilterOperator.Contains, + }, + { + Header: t('Users'), + key: 'user_ids', + id: 'user_ids', + input: 'select', + operator: FilterOperator.RelationOneMany, + unfilteredLabel: t('All'), + selects: users?.map(user => ({ + label: user.username, + value: user.id, + })), + loading: loadingState.users, + }, + { + Header: t('Permissions'), + key: 'permission_ids', + id: 'permission_ids', + input: 'select', + operator: FilterOperator.RelationOneMany, + unfilteredLabel: t('All'), + selects: permissions?.map(permission => ({ + label: permission.label, + value: permission.id, + })), + loading: loadingState.permissions, + }, + ], + [permissions, users, loadingState.users, loadingState.permissions], + ); + + const emptyState = { + title: t('No roles yet'), + image: 'filter-results.svg', + ...(isAdmin && { + buttonAction: () => { + openModal(ModalType.ADD); + }, + buttonText: ( + <> + <Icons.PlusOutlined + iconColor={theme.colors.primary.light5} + iconSize="m" + css={css` + margin: auto ${theme.gridUnit * 2}px auto 0; + vertical-align: text-top; + `} + /> + {t('Role')} + </> + ), + }), + }; + + return ( + <> + <SubMenu name={t('List Roles')} buttons={subMenuButtons} /> + <RoleListAddModal + onHide={() => closeModal(ModalType.ADD)} + show={modalState.add} + onSave={() => { + refreshData(); + closeModal(ModalType.ADD); + }} + permissions={permissions} + /> + {modalState.edit && currentRole && ( + <RoleListEditModal + role={currentRole} + show={modalState.edit} + onHide={() => closeModal(ModalType.EDIT)} + onSave={() => { + refreshData(); + closeModal(ModalType.EDIT); + fetchUsers(); + }} + permissions={permissions} + users={users} + /> + )} + {modalState.duplicate && currentRole && ( + <RoleListDuplicateModal + role={currentRole} + show={modalState.duplicate} + onHide={() => closeModal(ModalType.DUPLICATE)} + onSave={() => { + refreshData(); + closeModal(ModalType.DUPLICATE); + }} + /> + )} + {roleCurrentlyDeleting && ( + <DeleteModal + description={t('This action will permanently delete the role.')} + onConfirm={() => { + if (roleCurrentlyDeleting) { + handleRoleDelete(roleCurrentlyDeleting); + } + }} + onHide={() => setRoleCurrentlyDeleting(null)} + open + title={t('Delete Role?')} + /> + )} + <ConfirmStatusChange + title={t('Please confirm')} + description={t('Are you sure you want to delete the selected roles?')} + onConfirm={handleBulkRolesDelete} + > + {confirmDelete => { + const bulkActions: ListViewProps['bulkActions'] = isAdmin + ? [ + { + key: 'delete', + name: t('Delete'), + onSelect: confirmDelete, + type: 'danger', + }, + ] + : []; + + return ( + <ListView<RoleObject> + className="role-list-view" + columns={columns} + count={rolesCount} + data={roles} + fetchData={fetchData} + filters={filters} + initialSort={initialSort} + loading={loading} + pageSize={PAGE_SIZE} + bulkActions={bulkActions} + bulkSelectEnabled={bulkSelectEnabled} + disableBulkSelect={toggleBulkSelect} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + emptyState={emptyState} + refreshData={refreshData} + /> + ); + }} + </ConfirmStatusChange> + </> + ); +} + +export default withToasts(RolesList); diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index 30ce65f069537aba5e44550fb45a9f5f0a62298f..a9ade6b75ee77c8d10d5275ff0553f5a30547ae2 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -18,6 +18,8 @@ */ import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import { lazy, ComponentType, ComponentProps } from 'react'; +import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; +import getBootstrapData from 'src/utils/getBootstrapData'; // not lazy loaded since this is the home page. import Home from 'src/pages/Home'; @@ -123,6 +125,10 @@ const RowLevelSecurityList = lazy( ), ); +const RolesList = lazy( + () => import(/* webpackChunkName: "RolesList" */ 'src/pages/RolesList'), +); + type Routes = { path: string; Component: ComponentType; @@ -238,6 +244,16 @@ if (isFeatureEnabled(FeatureFlag.TaggingSystem)) { }); } +const user = getBootstrapData()?.user; +const isAdmin = isUserAdmin(user); + +if (isAdmin) { + routes.push({ + path: '/roles/', + Component: RolesList, + }); +} + const frontEndRoutes: Record<string, boolean> = routes .map(r => r.path) .reduce( diff --git a/superset/config.py b/superset/config.py index d9c35e04fab24ab84464e4f712270325d009488c..78d6567a6a996942d217e5f4935a5e35d21d232a 100644 --- a/superset/config.py +++ b/superset/config.py @@ -1253,6 +1253,7 @@ ENABLE_CHUNK_ENCODING = False SILENCE_FAB = True FAB_ADD_SECURITY_VIEWS = True +FAB_ADD_SECURITY_API = True FAB_ADD_SECURITY_PERMISSION_VIEW = False FAB_ADD_SECURITY_VIEW_MENU_VIEW = False FAB_ADD_SECURITY_PERMISSION_VIEWS_VIEW = False diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 1a09d8b24c7d9fa26081678233d3b8b440855fac..cd3510abbf49071cb95a3bb5d3565a4f184b7a06 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -153,7 +153,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods from superset.reports.api import ReportScheduleRestApi from superset.reports.logs.api import ReportExecutionLogRestApi from superset.row_level_security.api import RLSRestApi - from superset.security.api import SecurityRestApi + from superset.security.api import RoleRestAPI, SecurityRestApi from superset.sqllab.api import SqlLabRestApi from superset.sqllab.permalink.api import SqlLabPermalinkRestApi from superset.tags.api import TagRestApi @@ -175,6 +175,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods from superset.views.explore import ExplorePermalinkView, ExploreView from superset.views.log.api import LogRestApi from superset.views.log.views import LogModelView + from superset.views.roles import RolesListView from superset.views.sql_lab.views import ( SavedQueryView, TableSchemaView, @@ -265,6 +266,15 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods category_icon="", ) + appbuilder.add_view( + RolesListView, + "List Roles", + label=__("List Roles"), + category="Security", + category_label=__("Security"), + icon="fa-lock", + ) + appbuilder.add_view( DynamicPluginsView, "Plugins", @@ -305,6 +315,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods appbuilder.add_view_no_menu(TaggedObjectsModelView) appbuilder.add_view_no_menu(TagView) appbuilder.add_view_no_menu(ReportView) + appbuilder.add_view_no_menu(RoleRestAPI) # # Add links diff --git a/superset/security/api.py b/superset/security/api.py index 02bf6b7101ea8e1b72429c454155c02ca33cbc3a..756f1d7bbd3e1500cdd03c8a18e38ee7c31b58e1 100644 --- a/superset/security/api.py +++ b/superset/security/api.py @@ -19,16 +19,21 @@ from typing import Any from flask import current_app, request, Response from flask_appbuilder import expose -from flask_appbuilder.api import safe +from flask_appbuilder.api import rison, safe +from flask_appbuilder.api.schemas import get_list_schema from flask_appbuilder.security.decorators import permission_name, protect +from flask_appbuilder.security.sqla.models import Role from flask_wtf.csrf import generate_csrf from marshmallow import EXCLUDE, fields, post_load, Schema, ValidationError +from sqlalchemy import asc, desc +from sqlalchemy.orm import joinedload from superset.commands.dashboard.embedded.exceptions import ( EmbeddedDashboardNotFoundError, ) +from superset.commands.exceptions import ForbiddenError from superset.exceptions import SupersetGenericErrorException -from superset.extensions import event_logger +from superset.extensions import db, event_logger from superset.security.guest_token import GuestTokenResourceType from superset.views.base_api import BaseSupersetApi, statsd_metrics @@ -76,6 +81,19 @@ class GuestTokenCreateSchema(PermissiveSchema): rls = fields.List(fields.Nested(RlsRuleSchema), required=True) +class RoleResponseSchema(PermissiveSchema): + id = fields.Integer() + name = fields.String() + user_ids = fields.List(fields.Integer()) + permission_ids = fields.List(fields.Integer()) + + +class RolesResponseSchema(PermissiveSchema): + count = fields.Integer() + ids = fields.List(fields.Integer()) + result = fields.List(fields.Nested(RoleResponseSchema)) + + guest_token_create_schema = GuestTokenCreateSchema() @@ -172,3 +190,146 @@ class SecurityRestApi(BaseSupersetApi): return self.response_400(message=error.message) except ValidationError as error: return self.response_400(message=error.messages) + + +class RoleRestAPI(BaseSupersetApi): + """ + APIs for listing roles with usersIds and permissionsIds and possibility to update + users of roles + """ + + resource_name = "security/roles" + allow_browser_login = True + openapi_spec_tag = "Security Roles" + openapi_spec_component_schemas = ( + RoleResponseSchema, + RolesResponseSchema, + ) + + @expose("/search/", methods=["GET"]) + @event_logger.log_this + @protect() + @safe + @rison(get_list_schema) + @statsd_metrics + @permission_name("list_roles") + def get_list(self, **kwargs: Any) -> Response: + """ + List roles, including associated user IDs and permission IDs. + + --- + get: + summary: List roles + description: Fetch a paginated list of roles with user and permission IDs. + parameters: + - in: query + name: q + schema: + type: object + properties: + order_column: + type: string + enum: ["id", "name"] + default: "id" + order_direction: + type: string + enum: ["asc", "desc"] + default: "asc" + page: + type: integer + default: 0 + page_size: + type: integer + default: 10 + filters: + type: array + items: + type: object + properties: + col: + type: string + enum: ["user_ids", "permission_ids", "name"] + value: + type: string + responses: + 200: + description: Successfully retrieved roles + content: + application/json: + schema: RolesResponseSchema + 400: + description: Bad request (invalid input) + content: + application/json: + schema: + type: object + properties: + error: + type: string + 403: + description: Forbidden + content: + application/json: + schema: + type: object + properties: + error: + type: string + """ + try: + args = kwargs.get("rison", {}) + order_column = args.get("order_column", "id") + order_direction = args.get("order_direction", "asc") + + valid_columns = ["id", "name"] + if order_column not in valid_columns: + return self.response_400( + message=f"Invalid order column: {order_column}" + ) + + order_by = getattr(Role, order_column) + order_by = asc(order_by) if order_direction == "asc" else desc(order_by) + + page = args.get("page", 0) + page_size = args.get("page_size", 10) + + query = db.session.query(Role).options( + joinedload(Role.permissions), joinedload(Role.user) + ) + + filters = args.get("filters", []) + filter_dict = {f["col"]: f["value"] for f in filters if "col" in f} + + if "user_ids" in filter_dict: + query = query.filter(Role.user.any(id=filter_dict["user_ids"])) + + if "permission_ids" in filter_dict: + query = query.filter( + Role.permissions.any(id=filter_dict["permission_ids"]) + ) + + if "name" in filter_dict: + query = query.filter(Role.name.ilike(f"%{filter_dict['name']}%")) + + roles = ( + query.order_by(order_by).offset(page * page_size).limit(page_size).all() + ) + + return self.response( + 200, + result=[ + { + "id": role.id, + "name": role.name, + "user_ids": [user.id for user in role.user], + "permission_ids": [perm.id for perm in role.permissions], + } + for role in roles + ], + count=query.count(), + ids=[role.id for role in roles], + ) + except ForbiddenError as e: + return self.response_403(message=str(e)) + except Exception as e: + return self.response_500(message=str(e)) diff --git a/superset/security/manager.py b/superset/security/manager.py index d11e0f3e41c0c4b6e158ab81b622691a5fda2ae8..009e7f314cbcf2fc2730a37460a574f5891083a4 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -25,6 +25,7 @@ from typing import Any, Callable, cast, NamedTuple, Optional, TYPE_CHECKING from flask import current_app, Flask, g, Request from flask_appbuilder import Model +from flask_appbuilder.security.sqla.apis import RoleApi from flask_appbuilder.security.sqla.manager import SecurityManager from flask_appbuilder.security.sqla.models import ( assoc_group_role, @@ -40,7 +41,6 @@ from flask_appbuilder.security.sqla.models import ( from flask_appbuilder.security.views import ( PermissionModelView, PermissionViewModelView, - RoleModelView, UserModelView, ViewMenuModelView, ) @@ -126,8 +126,19 @@ class SupersetRoleListWidget(ListWidget): # pylint: disable=too-few-public-meth super().__init__(**kwargs) +class SupersetRoleApi(RoleApi): + """ + Overriding the RoleApi to be able to delete roles with permissions + """ + + def pre_delete(self, item: Model) -> None: + """ + Overriding this method to be able to delete items when they have constraints + """ + item.permissions = [] + + UserModelView.list_widget = SupersetSecurityListWidget -RoleModelView.list_widget = SupersetRoleListWidget PermissionViewModelView.list_widget = SupersetSecurityListWidget PermissionModelView.list_widget = SupersetSecurityListWidget @@ -138,15 +149,10 @@ UserModelView.include_route_methods = RouteMethod.CRUD_SET | { RouteMethod.ACTION_POST, "userinfo", } -RoleModelView.include_route_methods = RouteMethod.CRUD_SET PermissionViewModelView.include_route_methods = {RouteMethod.LIST} PermissionModelView.include_route_methods = {RouteMethod.LIST} ViewMenuModelView.include_route_methods = {RouteMethod.LIST} -RoleModelView.list_columns = ["name"] -RoleModelView.edit_columns = ["name", "permissions", "user"] -RoleModelView.related_views = [] - def freeze_value(value: Any) -> str: """ @@ -218,6 +224,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods userstatschartview = None READ_ONLY_MODEL_VIEWS = {"Database", "DynamicPlugin"} + role_api = SupersetRoleApi + USER_MODEL_VIEWS = { "RegisterUserModelView", "UserDBModelView", @@ -251,6 +259,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods "User Registrations", "User's Statistics", # Guarding all AB_ADD_SECURITY_API = True REST APIs + "RoleRestAPI", "Role", "Permission", "PermissionViewMenu", @@ -279,6 +288,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods } ADMIN_ONLY_PERMISSIONS = { + "update_roles_users", + "list_roles", "can_update_role", "all_query_access", "can_grant_guest_token", @@ -2749,3 +2760,23 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods return current_app.config["AUTH_ROLE_ADMIN"] in [ role.name for role in self.get_user_roles() ] + + # temporal change to remove the roles view from the security menu, + # after migrating all views to frontend, we will set FAB_ADD_SECURITY_VIEWS = False + def register_views(self) -> None: + super().register_views() + + for view in list(self.appbuilder.baseviews): + if ( + isinstance(view, self.rolemodelview.__class__) + and getattr(view, "route_base", None) == "/roles" + ): + self.appbuilder.baseviews.remove(view) + + security_menu = next( + (m for m in self.appbuilder.menu.get_list() if m.name == "Security"), None + ) + if security_menu: + for item in list(security_menu.childs): + if item.name == "List Roles": + security_menu.childs.remove(item) diff --git a/superset/views/roles.py b/superset/views/roles.py new file mode 100644 index 0000000000000000000000000000000000000000..29f293f51e578f745a35be03784236930aeab93d --- /dev/null +++ b/superset/views/roles.py @@ -0,0 +1,34 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from flask_appbuilder import permission_name +from flask_appbuilder.api import expose +from flask_appbuilder.security.decorators import has_access + +from superset.superset_typing import FlaskResponse + +from .base import BaseSupersetView + + +class RolesListView(BaseSupersetView): + route_base = "/" + class_permission_name = "security" + + @expose("/roles/") + @has_access + @permission_name("read") + def list(self) -> FlaskResponse: + return super().render_app_template() diff --git a/tests/integration_tests/core_tests.py b/tests/integration_tests/core_tests.py index 6b28ac13bfdd3d38e13414a1cc2be56eacb61d48..8fe424b1bdfc4ef8b7ef73db55bfcc8b2fe6ea83 100644 --- a/tests/integration_tests/core_tests.py +++ b/tests/integration_tests/core_tests.py @@ -161,7 +161,7 @@ class TestCore(SupersetTestCase): role = security_manager.find_role(role_name) view_menus = [p.view_menu.name for p in role.permissions] assert_func("ResetPasswordView", view_menus) - assert_func("RoleModelView", view_menus) + assert_func("RoleRestAPI", view_menus) assert_func("Security", view_menus) assert_func("SQL Lab", view_menus) diff --git a/tests/integration_tests/security/api_tests.py b/tests/integration_tests/security/api_tests.py index 3bb85c4cad40fd1bdf9c515a5e092c734c623497..5667c64ca1101080ebf0d1aa77fc1eb2d0d07758 100644 --- a/tests/integration_tests/security/api_tests.py +++ b/tests/integration_tests/security/api_tests.py @@ -219,6 +219,7 @@ class TestSecurityGuestTokenApiTokenValidator(SupersetTestCase): class TestSecurityRolesApi(SupersetTestCase): uri = "api/v1/security/roles/" # noqa: F541 + show_uri = "api/v1/security/roles/search/" @with_config({"FAB_ADD_SECURITY_API": True}) def test_get_security_roles_admin(self): @@ -276,3 +277,19 @@ class TestSecurityRolesApi(SupersetTestCase): content_type="application/json", ) self.assert403(response) + + def test_show_roles_admin(self): + """ + Security API: Admin should be able to show roles with permissions and users + """ + self.login(ADMIN_USERNAME) + response = self.client.get(self.show_uri) + self.assert200(response) + + def test_show_roles_gamma(self): + """ + Security API: Gamma should not be able to show roles + """ + self.login(GAMMA_USERNAME) + response = self.client.get(self.show_uri) + self.assert403(response) diff --git a/tests/unit_tests/security/api_test.py b/tests/unit_tests/security/api_test.py index 73227166c212dc807d6c49877b8bad6ab94c68c1..faeec96f558769a71ecd9c453837fa1e0b32907f 100644 --- a/tests/unit_tests/security/api_test.py +++ b/tests/unit_tests/security/api_test.py @@ -32,4 +32,9 @@ def test_csrf_not_exempt(app_context: None) -> None: "MenuApi", "SecurityApi", "OpenApi", + "PermissionViewMenuApi", + "SupersetRoleApi", + "UserApi", + "PermissionApi", + "ViewMenuApi", }