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,