You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by en...@apache.org on 2025/02/28 18:51:56 UTC
(superset) 01/01: feat(List Roles): Migrate FAB view
This is an automated email from the ASF dual-hosted git repository.
enzomartellucci pushed a commit to branch enxdev/feat/fab-list-roles-migration
in repository https://gitbox.apache.org/repos/asf/superset.git
commit cdf03454373542349b3e2c3c437d17920aa8531b
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Fri Feb 28 19:51:44 2025 +0100
feat(List Roles): Migrate FAB view
---
superset-frontend/src/features/home/SubMenu.tsx | 1 +
.../src/features/roles/RoleListAddModal.tsx | 158 +++++++
.../src/features/roles/RoleListDuplicateModal.tsx | 131 ++++++
.../src/features/roles/RoleListEditModal.tsx | 239 +++++++++++
superset-frontend/src/features/roles/types.ts | 54 +++
superset-frontend/src/pages/RolesList/index.tsx | 452 +++++++++++++++++++++
superset-frontend/src/views/CRUD/hooks.ts | 4 +-
superset-frontend/src/views/routes.tsx | 8 +
8 files changed, 1046 insertions(+), 1 deletion(-)
diff --git a/superset-frontend/src/features/home/SubMenu.tsx b/superset-frontend/src/features/home/SubMenu.tsx
index af1df5a59a..55770a8426 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';
+ disabled?: boolean;
}
export interface SubMenuProps {
diff --git a/superset-frontend/src/features/roles/RoleListAddModal.tsx b/superset-frontend/src/features/roles/RoleListAddModal.tsx
new file mode 100644
index 0000000000..b124a8e7bc
--- /dev/null
+++ b/superset-frontend/src/features/roles/RoleListAddModal.tsx
@@ -0,0 +1,158 @@
+/**
+ * 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 { useEffect, useState, useCallback } from 'react';
+import { t, SupersetClient } from '@superset-ui/core';
+import { AntdForm, Select } from 'src/components';
+import Modal from 'src/components/Modal';
+import { Input } from 'src/components/Input';
+import Button from 'src/components/Button';
+import { FormattedPermission } from './types';
+
+export interface AddRoleModalProps {
+ show: boolean;
+ onHide: () => void;
+ addDangerToast: (message: string) => void;
+ onSave: () => void;
+ permissions: FormattedPermission[];
+ addSuccessToast: (message: string) => void;
+}
+
+function AddRoleModal({
+ show,
+ onHide,
+ addDangerToast,
+ onSave,
+ permissions,
+ addSuccessToast,
+}: AddRoleModalProps) {
+ const [form] = AntdForm.useForm();
+ const FormItem = AntdForm.Item;
+ const [isSaving, setIsSaving] = useState(false);
+ const [roleName, setRoleName] = useState('');
+
+ const resetForm = useCallback(() => {
+ form.resetFields();
+ setIsSaving(false);
+ }, [form]);
+
+ const handleClose = useCallback(() => {
+ resetForm();
+ onHide();
+ }, [onHide, resetForm]);
+
+ useEffect(() => {
+ if (show) {
+ resetForm();
+ }
+ }, [show, resetForm]);
+
+ const handleFormSubmit = useCallback(
+ async values => {
+ try {
+ setIsSaving(true);
+ const { json: roleResponse } = await SupersetClient.post({
+ endpoint: '/api/v1/security/roles/',
+ jsonPayload: { name: values.roleName },
+ });
+
+ if (
+ values.selectedPermissions &&
+ values.selectedPermissions.length > 0
+ ) {
+ await SupersetClient.post({
+ endpoint: `/api/v1/security/roles/${roleResponse.id}/permissions`,
+ jsonPayload: {
+ permission_view_menu_ids: values.selectedPermissions,
+ },
+ });
+ }
+ addSuccessToast(t('Role successfully created!'));
+ resetForm();
+ onSave();
+ } catch (err) {
+ addDangerToast(t('Error while adding role'));
+ } finally {
+ setIsSaving(false);
+ }
+ },
+ [permissions, addDangerToast, addSuccessToast, onSave, resetForm],
+ );
+
+ return (
+ <Modal
+ show={show}
+ title={t('Add Role')}
+ onHide={handleClose}
+ footer={
+ <>
+ <Button
+ buttonStyle="secondary"
+ data-test="add-role-modal-cancel-button"
+ onClick={handleClose}
+ >
+ {t('Cancel')}
+ </Button>
+ <Button
+ buttonStyle="primary"
+ htmlType="submit"
+ onClick={() => form.submit()}
+ data-test="add-role-modal-save-button"
+ disabled={isSaving || !roleName.trim()}
+ >
+ {isSaving ? t('Saving...') : t('Save')}
+ </Button>
+ </>
+ }
+ >
+ <AntdForm form={form} layout="vertical" onFinish={handleFormSubmit}>
+ <FormItem
+ name="roleName"
+ label={t('Role Name')}
+ rules={[
+ { required: true, message: t('Role name is required') },
+ { whitespace: true, message: t('Role name cannot be empty') },
+ ]}
+ >
+ <Input
+ name="roleName"
+ data-test="role-name-input"
+ onChange={e => setRoleName(e.target.value)}
+ />
+ </FormItem>
+
+ <FormItem name="selectedPermissions" label={t('Permissions')}>
+ <Select
+ mode="multiple"
+ name="selectedPermissions"
+ options={permissions.map(p => ({
+ label: p.value,
+ value: p.id,
+ }))}
+ getPopupContainer={trigger =>
+ trigger.closest('.antd5-modal-content')
+ }
+ data-test="permissions-select"
+ />
+ </FormItem>
+ </AntdForm>
+ </Modal>
+ );
+}
+
+export default AddRoleModal;
diff --git a/superset-frontend/src/features/roles/RoleListDuplicateModal.tsx b/superset-frontend/src/features/roles/RoleListDuplicateModal.tsx
new file mode 100644
index 0000000000..4280ce8575
--- /dev/null
+++ b/superset-frontend/src/features/roles/RoleListDuplicateModal.tsx
@@ -0,0 +1,131 @@
+/**
+ * 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, useCallback, useEffect } from 'react';
+import { t, SupersetClient } from '@superset-ui/core';
+import Modal from 'src/components/Modal';
+import { Input } from 'src/components/Input';
+import Button from 'src/components/Button';
+import { AntdForm } from 'src/components';
+import { RoleObject } from 'src/pages/RolesList';
+
+export interface DuplicateRoleModalProps {
+ role: RoleObject;
+ show: boolean;
+ onHide: () => void;
+ addDangerToast: (message: string) => void;
+ onSave: () => void;
+ addSuccessToast: (message: string) => void;
+}
+
+function DuplicateRoleModal({
+ role,
+ show,
+ onHide,
+ addDangerToast,
+ onSave,
+ addSuccessToast,
+}: DuplicateRoleModalProps) {
+ const { name, permission_ids } = role;
+ const [isSaving, setIsSaving] = useState(false);
+ const [form] = AntdForm.useForm();
+ const FormItem = AntdForm.Item;
+ const [roleName, setRoleName] = useState('');
+
+ const handleClose = useCallback(() => {
+ form.resetFields();
+ setIsSaving(false);
+ onHide();
+ }, [onHide, form]);
+
+ const handleSave = async (values: { roleName: string }) => {
+ setIsSaving(true);
+
+ try {
+ const { json: roleResponse } = await SupersetClient.post({
+ endpoint: '/api/v1/security/roles/',
+ jsonPayload: { name: values.roleName },
+ });
+
+ if (permission_ids.length > 0) {
+ await SupersetClient.post({
+ endpoint: `/api/v1/security/roles/${roleResponse.id}/permissions`,
+ jsonPayload: { permission_view_menu_ids: permission_ids },
+ });
+ }
+
+ addSuccessToast(t('Role successfully duplicated!'));
+ handleClose();
+ onSave();
+ } catch (err) {
+ addDangerToast(t('Error while duplicating role'));
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ useEffect(() => {
+ if (show) {
+ form.resetFields();
+ }
+ }, [show, form]);
+
+ return (
+ <Modal
+ show={show}
+ title={t('Duplicate role %(name)s', { name })}
+ onHide={handleClose}
+ footer={
+ <>
+ <Button
+ buttonStyle="secondary"
+ data-test="duplicate-role-modal-cancel-button"
+ onClick={handleClose}
+ >
+ {t('Cancel')}
+ </Button>
+ <Button
+ disabled={isSaving || !roleName.trim()}
+ buttonStyle="primary"
+ htmlType="submit"
+ onClick={() => form.submit()}
+ data-test="duplicate-role-modal-save-button"
+ >
+ {isSaving ? t('Saving...') : t('Save')}
+ </Button>
+ </>
+ }
+ >
+ <AntdForm form={form} layout="vertical" onFinish={handleSave}>
+ <FormItem
+ name="roleName"
+ label={t('Role Name')}
+ rules={[{ required: true, message: t('Role name is required') }]}
+ >
+ <Input
+ name="roleName"
+ data-test="role-name-input"
+ onChange={e => setRoleName(e.target.value)}
+ />
+ </FormItem>
+ </AntdForm>
+ </Modal>
+ );
+}
+
+export default DuplicateRoleModal;
diff --git a/superset-frontend/src/features/roles/RoleListEditModal.tsx b/superset-frontend/src/features/roles/RoleListEditModal.tsx
new file mode 100644
index 0000000000..4054105465
--- /dev/null
+++ b/superset-frontend/src/features/roles/RoleListEditModal.tsx
@@ -0,0 +1,239 @@
+/**
+ * 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 { useEffect, useState, useCallback } from 'react';
+import { t, SupersetClient } from '@superset-ui/core';
+import Modal from 'src/components/Modal';
+import Tabs from 'src/components/Tabs';
+import { AntdForm, Select } from 'src/components/';
+import { Input } from 'src/components/Input';
+import Button from 'src/components/Button';
+import { RoleObject } from 'src/pages/RolesList';
+import TableView, { EmptyWrapperType } from 'src/components/TableView';
+import { FormattedPermission, UserObject } from 'src/features/roles/types';
+import { CellProps } from 'react-table';
+
+type EditRoleModalProps = {
+ show: boolean;
+ onHide: () => void;
+ addDangerToast: (message: string) => void;
+ addSuccessToast: (message: string) => void;
+ onSave: () => void;
+ 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 EditRoleModal({
+ show,
+ onHide,
+ addDangerToast,
+ role,
+ onSave,
+ addSuccessToast,
+ permissions,
+ users,
+}: EditRoleModalProps) {
+ const { id, name, permission_ids, user_ids } = role;
+ const [activeTabKey, setActiveTabKey] = useState(roleTabs.edit.key);
+ const [form] = AntdForm.useForm();
+ const FormItem = AntdForm.Item;
+ const [isSaving, setIsSaving] = useState(false);
+ const filteredUsers = users.filter(user =>
+ user?.roles.some(role => role.id === id),
+ );
+
+ const [roleName, setRoleName] = useState(name);
+
+ const resetForm = useCallback(() => {
+ form.resetFields();
+ setIsSaving(false);
+ }, [form]);
+
+ const handleClose = useCallback(() => {
+ resetForm();
+ onHide();
+ }, [onHide, resetForm]);
+
+ useEffect(() => {
+ if (show) {
+ resetForm();
+ }
+ }, [show, resetForm]);
+
+ const handleFormSubmit = useCallback(
+ async values => {
+ try {
+ setIsSaving(true);
+ await SupersetClient.put({
+ endpoint: `/api/v1/security/roles/${id}`,
+ jsonPayload: { name: values.roleName },
+ });
+
+ await SupersetClient.post({
+ endpoint: `/api/v1/security/roles/${id}/permissions`,
+ jsonPayload: { permission_view_menu_ids: values.selectedPermissions },
+ });
+
+ await SupersetClient.put({
+ endpoint: `/api/v1/security/roles/${id}/users`,
+ jsonPayload: { user_ids: values.selectedUsers },
+ });
+
+ addSuccessToast(t('Role successfully updated!'));
+ resetForm();
+ onSave();
+ } catch (err) {
+ addDangerToast(t('Error while updating role'));
+ } finally {
+ setIsSaving(false);
+ }
+ },
+ [id, addDangerToast, addSuccessToast, onSave, resetForm],
+ );
+
+ return (
+ <Modal
+ show={show}
+ title={t('Edit Role')}
+ onHide={handleClose}
+ bodyStyle={{ height: '400px' }}
+ footer={
+ <>
+ <Button
+ buttonStyle="secondary"
+ data-test="edit-role-modal-cancel-button"
+ onClick={handleClose}
+ >
+ {t('Cancel')}
+ </Button>
+ <Button
+ buttonStyle="primary"
+ htmlType="submit"
+ onClick={() => form.submit()}
+ data-test="edit-role-modal-save-button"
+ disabled={isSaving || !roleName.trim()}
+ >
+ {isSaving ? t('Saving...') : t('Save')}
+ </Button>
+ </>
+ }
+ >
+ <Tabs
+ activeKey={activeTabKey}
+ onChange={activeKey => setActiveTabKey(activeKey)}
+ >
+ <Tabs.TabPane
+ tab={roleTabs.edit.name}
+ key={roleTabs.edit.key}
+ forceRender
+ >
+ <AntdForm
+ form={form}
+ layout="vertical"
+ onFinish={handleFormSubmit}
+ initialValues={{
+ roleName: name,
+ selectedPermissions: permission_ids,
+ selectedUsers: user_ids,
+ }}
+ >
+ <FormItem
+ name="roleName"
+ label={t('Role Name')}
+ rules={[{ required: true, message: t('Role name is required') }]}
+ >
+ <Input
+ name="roleName"
+ data-test="role-name-input"
+ onChange={e => setRoleName(e.target.value)}
+ />
+ </FormItem>
+ <FormItem name="selectedPermissions" label={t('Permissions')}>
+ <Select
+ mode="multiple"
+ name="selectedPermissions"
+ options={permissions.map(p => ({
+ label: p.value,
+ value: p.id,
+ }))}
+ getPopupContainer={trigger =>
+ trigger.closest('.antd5-modal-content')
+ }
+ data-test="permissions-select"
+ />
+ </FormItem>
+ <FormItem name="selectedUsers" label={t('Users')}>
+ <Select
+ mode="multiple"
+ name="selectedUsers"
+ options={users.map(u => ({ label: u.username, value: u.id }))}
+ data-test="users-select"
+ />
+ </FormItem>
+ </AntdForm>
+ </Tabs.TabPane>
+ <Tabs.TabPane tab={roleTabs.users.name} key={roleTabs.users.key}>
+ <TableView
+ columns={userColumns}
+ data={filteredUsers}
+ emptyWrapperType={EmptyWrapperType.Small}
+ />
+ </Tabs.TabPane>
+ </Tabs>
+ </Modal>
+ );
+}
+
+export default EditRoleModal;
diff --git a/superset-frontend/src/features/roles/types.ts b/superset-frontend/src/features/roles/types.ts
new file mode 100644
index 0000000000..28bb74fa82
--- /dev/null
+++ b/superset-frontend/src/features/roles/types.ts
@@ -0,0 +1,54 @@
+/**
+ * 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;
+};
diff --git a/superset-frontend/src/pages/RolesList/index.tsx b/superset-frontend/src/pages/RolesList/index.tsx
new file mode 100644
index 0000000000..a944d579d9
--- /dev/null
+++ b/superset-frontend/src/pages/RolesList/index.tsx
@@ -0,0 +1,452 @@
+/**
+ * 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 { t, SupersetClient } from '@superset-ui/core';
+import { useListViewResource } from 'src/views/CRUD/hooks';
+import { createErrorHandler } from 'src/views/CRUD/utils';
+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 { ModifiedInfo } from 'src/components/AuditInfo';
+import {
+ FormattedPermission,
+ PermissionResource,
+ UserObject,
+} from 'src/features/roles/types';
+import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
+
+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>;
+ created_on?: string;
+ changed_on?: string;
+ user_ids: number[];
+};
+
+function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
+ const {
+ state: {
+ loading,
+ resourceCount: rolesCount,
+ resourceCollection: roles,
+ bulkSelectEnabled,
+ },
+ fetchData,
+ refreshData,
+ toggleBulkSelect,
+ } = useListViewResource<RoleObject>(
+ 'security/roles/search',
+ t('Role'),
+ addDangerToast,
+ );
+ const [roleModalOpen, setRoleModalOpen] = useState<boolean>(false);
+ const [currentRole, setCurrentRole] = useState<RoleObject | null>(null);
+ const [roleModalAddOpen, setRoleModalAddOpen] = useState<boolean>(false);
+ const [roleDuplicateModalOpen, setRoleDuplicateModalOpen] =
+ useState<boolean>(false);
+ 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 permissionsResponse = await SupersetClient.get({
+ endpoint: 'api/v1/security/permissions-resources/?q={"page_size":-1}',
+ });
+
+ const formattedPermissions = permissionsResponse.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,
+ }),
+ );
+ setPermissions(formattedPermissions);
+ } catch (err) {
+ addDangerToast(t('Error while fetching permissions'));
+ } finally {
+ setLoadingState(prev => ({ ...prev, permissions: false }));
+ }
+ }, []);
+
+ const fetchUsers = useCallback(async () => {
+ try {
+ const usersResponse = await SupersetClient.get({
+ endpoint: 'api/v1/security/users/?q={"page_size":-1}',
+ });
+ setUsers(usersResponse.json.result);
+ } catch (err) {
+ addDangerToast(t('Error while fetching users'));
+ } finally {
+ setLoadingState(prev => ({ ...prev, users: false }));
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchPermissions();
+ }, [fetchPermissions]);
+
+ useEffect(() => {
+ fetchUsers();
+ }, [fetchUsers]);
+
+ const handleRoleDelete = ({ id, name }: RoleObject) => {
+ SupersetClient.delete({
+ endpoint: `/api/v1/security/roles/${id}`,
+ }).then(
+ () => {
+ refreshData();
+ setRoleCurrentlyDeleting(null);
+ addSuccessToast(t('Deleted: %s', name));
+ },
+ createErrorHandler(errMsg =>
+ addDangerToast(t('There was an issue deleting %s:', name)),
+ ),
+ );
+ };
+
+ const handleBulkRolesDelete = (rolesToDelete: RoleObject[]) => {
+ const deletedRoleNames: string[] = [];
+
+ Promise.all(
+ rolesToDelete.map(role =>
+ SupersetClient.delete({ endpoint: `api/v1/security/roles/${role.id}` })
+ .then(() => {
+ deletedRoleNames.push(role.name);
+ })
+ .catch(err => {
+ addDangerToast(t('Error deleting %s: %s', role.name, err.message));
+ }),
+ ),
+ )
+ .then(() => {
+ if (deletedRoleNames.length > 0) {
+ addSuccessToast(t('Deleted roles: %s', deletedRoleNames.join(', ')));
+ }
+ })
+ .finally(() => {
+ refreshData();
+ });
+ };
+
+ function handleRoleEdit(role: RoleObject | null) {
+ setCurrentRole(role);
+ setRoleModalOpen(true);
+ }
+
+ function handleRoleDuplicate(role: RoleObject | null) {
+ setCurrentRole(role);
+ setRoleDuplicateModalOpen(true);
+ }
+
+ function handleRoleAdd() {
+ setRoleModalAddOpen(true);
+ }
+
+ function handleRoleModalAddSave() {
+ refreshData();
+ setRoleModalAddOpen(false);
+ }
+
+ function handleRoleModalEditSave() {
+ refreshData();
+ setRoleModalOpen(false);
+ fetchUsers();
+ }
+
+ function handleRoleModalDuplicateSave() {
+ refreshData();
+ setRoleDuplicateModalOpen(false);
+ }
+
+ const initialSort = [{ id: 'name', desc: true }];
+ const columns = useMemo(
+ () => [
+ {
+ accessor: 'name',
+ Header: t('Name'),
+ Cell: ({
+ row: {
+ original: { id, name },
+ },
+ }: any) => <span>{name}</span>,
+ },
+ {
+ Cell: ({
+ row: {
+ original: {
+ changed_on_delta_humanized: changedOn,
+ changed_by: changedBy,
+ },
+ },
+ }: any) => <ModifiedInfo date={changedOn} user={changedBy} />,
+ Header: t('Last modified'),
+ accessor: 'changed_on',
+ size: 'xl',
+ },
+ {
+ 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 = () => handleRoleEdit(original);
+ const handleDelete = () => setRoleCurrentlyDeleting(original);
+ const handleDuplicate = () => handleRoleDuplicate(original);
+
+ const actions = isAdmin
+ ? [
+ {
+ label: 'edit-action',
+ tooltip: t('Edit role'),
+ placement: 'bottom',
+ icon: 'Edit',
+ onClick: handleEdit,
+ },
+ {
+ label: 'duplicate-action',
+ tooltip: t('Duplicate role'),
+ placement: 'bottom',
+ icon: 'Copy',
+ onClick: handleDuplicate,
+ },
+ {
+ label: 'delete-action',
+ tooltip: t('Delete role'),
+ placement: 'bottom',
+ icon: 'Trash',
+ 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: (
+ <>
+ <i className="fa fa-plus" /> {t('Role')}
+ </>
+ ),
+ buttonStyle: 'primary',
+ onClick: handleRoleAdd,
+ disabled: loadingState.permissions,
+ },
+ {
+ 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,
+ })),
+ },
+ {
+ 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,
+ })),
+ },
+ ],
+ [permissions, users],
+ );
+
+ const emptyState = {
+ title: t('No roles yet'),
+ image: 'filter-results.svg',
+ buttonAction: () => handleRoleAdd(),
+ buttonText: (
+ <>
+ <i className="fa fa-plus" /> {t('Role')}
+ </>
+ ),
+ };
+
+ return (
+ <>
+ <SubMenu name={t('List Roles')} buttons={subMenuButtons} />
+ <RoleListAddModal
+ addDangerToast={addDangerToast}
+ onHide={() => setRoleModalAddOpen(false)}
+ show={roleModalAddOpen}
+ onSave={handleRoleModalAddSave}
+ permissions={permissions}
+ addSuccessToast={addSuccessToast}
+ />
+ {roleModalOpen && currentRole && (
+ <RoleListEditModal
+ role={currentRole}
+ show={roleModalOpen}
+ onHide={() => setRoleModalOpen(false)}
+ onSave={handleRoleModalEditSave}
+ addDangerToast={addDangerToast}
+ addSuccessToast={addSuccessToast}
+ permissions={permissions}
+ users={users}
+ />
+ )}
+ {roleDuplicateModalOpen && currentRole && (
+ <RoleListDuplicateModal
+ role={currentRole}
+ show={roleDuplicateModalOpen}
+ onHide={() => setRoleDuplicateModalOpen(false)}
+ onSave={handleRoleModalDuplicateSave}
+ addDangerToast={addDangerToast}
+ addSuccessToast={addSuccessToast}
+ />
+ )}
+ {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/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts
index a082bd971a..ee56eee531 100644
--- a/superset-frontend/src/views/CRUD/hooks.ts
+++ b/superset-frontend/src/views/CRUD/hooks.ts
@@ -98,8 +98,10 @@ export function useListViewResource<D extends object = any>(
useEffect(() => {
if (!infoEnable) return;
+ // temporal change
+ const cleanedResource = resource.replace(/\/search$/, '');
SupersetClient.get({
- endpoint: `/api/v1/${resource}/_info?q=${rison.encode({
+ endpoint: `/api/v1/${cleanedResource}/_info?q=${rison.encode({
keys: ['permissions'],
})}`,
}).then(
diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx
index 30ce65f069..2db43c6905 100644
--- a/superset-frontend/src/views/routes.tsx
+++ b/superset-frontend/src/views/routes.tsx
@@ -123,6 +123,10 @@ const RowLevelSecurityList = lazy(
),
);
+const RolesList = lazy(
+ () => import(/* webpackChunkName: "RolesList" */ 'src/pages/RolesList'),
+);
+
type Routes = {
path: string;
Component: ComponentType;
@@ -213,6 +217,10 @@ export const routes: Routes = [
path: '/dataset/add/',
Component: DatasetCreation,
},
+ {
+ path: '/roles/',
+ Component: RolesList,
+ },
{
path: '/dataset/:datasetId',
Component: DatasetCreation,