This is an automated email from the ASF dual-hosted git repository.

diegopucci pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 013379eb86 feat(List Users): Migrate List Users FAB to React (#32882)
013379eb86 is described below

commit 013379eb86276636d19f6dea159607a5c967b9b9
Author: Enzo Martellucci <[email protected]>
AuthorDate: Tue Apr 15 16:04:28 2025 +0200

    feat(List Users): Migrate List Users FAB to React (#32882)
---
 .../src/components/Checkbox/Checkbox.tsx           |   4 +-
 .../src/components/ListView/Filters/DateRange.tsx  |  25 +-
 .../components/ListView/Filters/NumericalRange.tsx | 134 +++++
 .../src/components/ListView/Filters/index.tsx      |  19 +
 superset-frontend/src/components/ListView/types.ts |   9 +-
 .../src/features/users/UserListModal.tsx           | 250 +++++++++
 .../Checkbox.tsx => features/users/types.ts}       |  45 +-
 .../Checkbox.tsx => features/users/utils.ts}       |  59 +-
 .../src/pages/UsersList/UsersList.test.tsx         | 192 +++++++
 superset-frontend/src/pages/UsersList/index.tsx    | 598 +++++++++++++++++++++
 superset-frontend/src/views/CRUD/hooks.ts          |   4 +-
 superset-frontend/src/views/routes.tsx             |  25 +-
 superset/initialization/__init__.py                |   9 +
 superset/security/manager.py                       |  48 +-
 .../api_test.py => superset/views/users_list.py    |  36 +-
 tests/unit_tests/security/api_test.py              |   2 +-
 16 files changed, 1330 insertions(+), 129 deletions(-)

diff --git a/superset-frontend/src/components/Checkbox/Checkbox.tsx 
b/superset-frontend/src/components/Checkbox/Checkbox.tsx
index 941b97aa53..931a45fed3 100644
--- a/superset-frontend/src/components/Checkbox/Checkbox.tsx
+++ b/superset-frontend/src/components/Checkbox/Checkbox.tsx
@@ -21,7 +21,7 @@ import { styled } from '@superset-ui/core';
 import { CheckboxChecked, CheckboxUnchecked } from 'src/components/Checkbox';
 
 export interface CheckboxProps {
-  checked: boolean;
+  checked?: boolean;
   onChange: (val?: boolean) => void;
   style?: CSSProperties;
   className?: string;
@@ -35,7 +35,7 @@ const Styles = styled.span`
 `;
 
 export default function Checkbox({
-  checked,
+  checked = false,
   onChange,
   style,
   className,
diff --git a/superset-frontend/src/components/ListView/Filters/DateRange.tsx 
b/superset-frontend/src/components/ListView/Filters/DateRange.tsx
index bf03012eed..030d462f8e 100644
--- a/superset-frontend/src/components/ListView/Filters/DateRange.tsx
+++ b/superset-frontend/src/components/ListView/Filters/DateRange.tsx
@@ -35,11 +35,12 @@ import { useLocale } from 'src/hooks/useLocale';
 import { BaseFilter, FilterHandler } from './Base';
 
 interface DateRangeFilterProps extends BaseFilter {
-  onSubmit: (val: number[]) => void;
+  onSubmit: (val: number[] | string[]) => void;
   name: string;
+  dateFilterValueType?: 'unix' | 'iso';
 }
 
-type ValueState = [number, number];
+type ValueState = [number, number] | [string, string] | null;
 
 const RangeFilterContainer = styled.div`
   display: inline-flex;
@@ -50,7 +51,12 @@ const RangeFilterContainer = styled.div`
 `;
 
 function DateRangeFilter(
-  { Header, initialValue, onSubmit }: DateRangeFilterProps,
+  {
+    Header,
+    initialValue,
+    onSubmit,
+    dateFilterValueType = 'unix',
+  }: DateRangeFilterProps,
   ref: RefObject<FilterHandler>,
 ) {
   const [value, setValue] = useState<ValueState | null>(initialValue ?? null);
@@ -85,11 +91,14 @@ function DateRangeFilter(
               onSubmit([]);
               return;
             }
-            const changeValue = [
-              dayjsRange[0]?.valueOf() ?? 0,
-              dayjsRange[1]?.valueOf() ?? 0,
-            ] as ValueState;
-            setValue(changeValue);
+            const changeValue =
+              dateFilterValueType === 'iso'
+                ? [dayjsRange[0].toISOString(), dayjsRange[1].toISOString()]
+                : [
+                    dayjsRange[0]?.valueOf() ?? 0,
+                    dayjsRange[1]?.valueOf() ?? 0,
+                  ];
+            setValue(changeValue as ValueState);
             onSubmit(changeValue);
           }}
         />
diff --git 
a/superset-frontend/src/components/ListView/Filters/NumericalRange.tsx 
b/superset-frontend/src/components/ListView/Filters/NumericalRange.tsx
new file mode 100644
index 0000000000..04c4f29fec
--- /dev/null
+++ b/superset-frontend/src/components/ListView/Filters/NumericalRange.tsx
@@ -0,0 +1,134 @@
+/**
+ * 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, forwardRef, useImperativeHandle, RefObject } from 'react';
+import { styled, t } from '@superset-ui/core';
+import { InputNumber } from 'src/components/Input';
+import { FormLabel } from 'src/components/Form';
+import { BaseFilter, FilterHandler } from './Base';
+
+const RangeFilterContainer = styled.div`
+  display: inline-flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: flex-start;
+  width: 360px;
+`;
+
+const InputContainer = styled.div`
+  display: flex;
+  align-items: center;
+  width: 100%;
+  position: relative;
+`;
+
+const StyledDivider = styled.span`
+  margin: 0 ${({ theme }) => theme.gridUnit * 2}px;
+  color: ${({ theme }) => theme.colors.grayscale.base};
+  font-weight: ${({ theme }) => theme.typography.weights.bold};
+  font-size: ${({ theme }) => theme.typography.sizes.m}px;
+`;
+
+const ErrorMessage = styled.div`
+  color: ${({ theme }) => theme.colors.error.base};
+  font-size: ${({ theme }) => theme.typography.sizes.s}px;
+  font-weight: ${({ theme }) => theme.typography.weights.bold};
+  position: absolute;
+  bottom: -50%;
+  left: 0;
+`;
+
+interface NumericalRangeFilterProps extends BaseFilter {
+  onSubmit: (val: [number | null, number | null]) => void;
+  name: string;
+  min?: number;
+  max?: number;
+}
+
+function NumericalRangeFilter(
+  { Header, initialValue, onSubmit }: NumericalRangeFilterProps,
+  ref: RefObject<FilterHandler>,
+) {
+  const [value, setValue] = useState<[number | null, number | null]>(
+    initialValue ?? [null, null],
+  );
+  const [hasError, setHasError] = useState(false);
+
+  const handleMinChange = (newMin: number | null) => {
+    const newValue: [number | null, number | null] = [newMin, value[1]];
+    setValue(newValue);
+
+    if (newMin !== null && value[1] !== null && newMin >= value[1]) {
+      setHasError(true);
+      return;
+    }
+
+    setHasError(false);
+    onSubmit(newValue);
+  };
+  const handleMaxChange = (newMax: number | null) => {
+    const newValue: [number | null, number | null] = [value[0], newMax];
+    setValue(newValue);
+
+    if (value[0] !== null && newMax !== null && value[0] >= newMax) {
+      setHasError(true);
+      return;
+    }
+
+    setHasError(false);
+    onSubmit(newValue);
+  };
+
+  useImperativeHandle(ref, () => ({
+    clearFilter: () => {
+      setValue([null, null]);
+      setHasError(false);
+      onSubmit([null, null]);
+    },
+  }));
+
+  return (
+    <RangeFilterContainer>
+      <FormLabel>{Header}</FormLabel>
+      <InputContainer>
+        <InputNumber
+          value={value[0]}
+          onChange={handleMinChange}
+          placeholder={t('Value greater than')}
+          style={{ width: '100%' }}
+          data-test="numerical-filter-min-input"
+        />
+        <StyledDivider>-</StyledDivider>
+        <InputNumber
+          value={value[1]}
+          onChange={handleMaxChange}
+          placeholder={t('Value less than')}
+          style={{ width: '100%' }}
+          data-test="numerical-filter-max-input"
+        />
+        {hasError && (
+          <ErrorMessage>
+            {t('Minimum must be strictly less than maximum')}
+          </ErrorMessage>
+        )}
+      </InputContainer>
+    </RangeFilterContainer>
+  );
+}
+
+export default forwardRef(NumericalRangeFilter);
diff --git a/superset-frontend/src/components/ListView/Filters/index.tsx 
b/superset-frontend/src/components/ListView/Filters/index.tsx
index 8f64b67d05..5c794f15df 100644
--- a/superset-frontend/src/components/ListView/Filters/index.tsx
+++ b/superset-frontend/src/components/ListView/Filters/index.tsx
@@ -35,6 +35,7 @@ import {
 import SearchFilter from './Search';
 import SelectFilter from './Select';
 import DateRangeFilter from './DateRange';
+import NumericalRangeFilter from './NumericalRange';
 import { FilterHandler } from './Base';
 
 interface UIFiltersProps {
@@ -76,6 +77,9 @@ function UIFilters(
             toolTipDescription,
             onFilterUpdate,
             loading,
+            dateFilterValueType,
+            min,
+            max,
           },
           index,
         ) => {
@@ -136,6 +140,21 @@ function UIFilters(
                 key={key}
                 name={id}
                 onSubmit={value => updateFilterValue(index, value)}
+                dateFilterValueType={dateFilterValueType || 'unix'}
+              />
+            );
+          }
+          if (input === 'numerical_range') {
+            return (
+              <NumericalRangeFilter
+                ref={filterRefs[index]}
+                Header={Header}
+                initialValue={initialValue}
+                min={min}
+                max={max}
+                key={key}
+                name={id}
+                onSubmit={value => updateFilterValue(index, value)}
               />
             );
           }
diff --git a/superset-frontend/src/components/ListView/types.ts 
b/superset-frontend/src/components/ListView/types.ts
index 5b498070ac..6e0ff40743 100644
--- a/superset-frontend/src/components/ListView/types.ts
+++ b/superset-frontend/src/components/ListView/types.ts
@@ -48,7 +48,8 @@ export interface Filter {
     | 'select'
     | 'checkbox'
     | 'search'
-    | 'datetime_range';
+    | 'datetime_range'
+    | 'numerical_range';
   unfilteredLabel?: string;
   selects?: SelectOption[];
   onFilterOpen?: () => void;
@@ -60,6 +61,9 @@ export interface Filter {
   ) => Promise<{ data: SelectOption[]; totalCount: number }>;
   paginate?: boolean;
   loading?: boolean;
+  dateFilterValueType?: 'unix' | 'iso';
+  min?: number;
+  max?: number;
 }
 
 export type Filters = Filter[];
@@ -74,7 +78,8 @@ export type InnerFilterValue =
   | undefined
   | string[]
   | number[]
-  | { label: string; value: string | number };
+  | { label: string; value: string | number }
+  | [number | null, number | null];
 
 export interface FilterValue {
   id: string;
diff --git a/superset-frontend/src/features/users/UserListModal.tsx 
b/superset-frontend/src/features/users/UserListModal.tsx
new file mode 100644
index 0000000000..90ea5de749
--- /dev/null
+++ b/superset-frontend/src/features/users/UserListModal.tsx
@@ -0,0 +1,250 @@
+/**
+ * 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 { FormItem } from 'src/components/Form';
+import { Input } from 'src/components/Input';
+import Checkbox from 'src/components/Checkbox';
+import Select from 'src/components/Select/Select';
+import { Role, UserObject } from 'src/pages/UsersList';
+import { FormInstance } from 'src/components';
+import { BaseUserListModalProps, FormValues } from './types';
+import { createUser, updateUser } from './utils';
+
+export interface UserModalProps extends BaseUserListModalProps {
+  roles: Role[];
+  isEditMode?: boolean;
+  user?: UserObject;
+}
+
+function UserListModal({
+  show,
+  onHide,
+  onSave,
+  roles,
+  isEditMode = false,
+  user,
+}: UserModalProps) {
+  const { addDangerToast, addSuccessToast } = useToasts();
+  const handleFormSubmit = async (values: FormValues) => {
+    const handleError = async (err: any, action: 'create' | 'update') => {
+      let errorMessage =
+        action === 'create'
+          ? t('Error while adding user!')
+          : t('Error while updating user!');
+
+      if (err.status === 422) {
+        const errorData = await err.json();
+        const detail = errorData?.message || '';
+
+        if (detail.includes('duplicate key value')) {
+          if (detail.includes('ab_user_username_key')) {
+            errorMessage = t(
+              'This username is already taken. Please choose another one.',
+            );
+          } else if (detail.includes('ab_user_email_key')) {
+            errorMessage = t(
+              'This email is already associated with an account.',
+            );
+          }
+        }
+      }
+
+      addDangerToast(errorMessage);
+      throw err;
+    };
+
+    if (isEditMode) {
+      if (!user) {
+        throw new Error('User is required in edit mode');
+      }
+      try {
+        await updateUser(user.id, values);
+        addSuccessToast(t('User was successfully updated!'));
+      } catch (err) {
+        await handleError(err, 'update');
+      }
+    } else {
+      try {
+        await createUser(values);
+        addSuccessToast(t('User was successfully created!'));
+      } catch (err) {
+        await handleError(err, 'create');
+      }
+    }
+  };
+
+  const requiredFields = isEditMode
+    ? ['first_name', 'last_name', 'username', 'email', 'roles']
+    : [
+        'first_name',
+        'last_name',
+        'username',
+        'email',
+        'password',
+        'roles',
+        'confirmPassword',
+      ];
+
+  const initialValues = {
+    ...user,
+    roles: user?.roles.map(role => role.id) || [],
+  };
+
+  return (
+    <FormModal
+      show={show}
+      onHide={onHide}
+      title={isEditMode ? t('Edit User') : t('Add User')}
+      onSave={onSave}
+      formSubmitHandler={handleFormSubmit}
+      requiredFields={requiredFields}
+      initialValues={initialValues}
+    >
+      {(form: FormInstance) => (
+        <>
+          <FormItem
+            name="first_name"
+            label={t('First name')}
+            rules={[{ required: true, message: t('First name is required') }]}
+          >
+            <Input
+              name="first_name"
+              placeholder={t("Enter the user's first name")}
+            />
+          </FormItem>
+          <FormItem
+            name="last_name"
+            label={t('Last name')}
+            rules={[{ required: true, message: t('Last name is required') }]}
+          >
+            <Input
+              name="last_name"
+              placeholder={t("Enter the user's last name")}
+            />
+          </FormItem>
+          <FormItem
+            name="username"
+            label={t('Username')}
+            rules={[{ required: true, message: t('Username is required') }]}
+          >
+            <Input
+              name="username"
+              placeholder={t("Enter the user's username")}
+            />
+          </FormItem>
+          <FormItem
+            name="active"
+            label={t('Is active?')}
+            valuePropName="checked"
+          >
+            <Checkbox
+              onChange={checked => {
+                form.setFieldsValue({ isActive: checked });
+              }}
+            />
+          </FormItem>
+          <FormItem
+            name="email"
+            label={t('Email')}
+            rules={[
+              { required: true, message: t('Email is required') },
+              {
+                type: 'email',
+                message: t('Please enter a valid email address'),
+              },
+            ]}
+          >
+            <Input name="email" placeholder={t("Enter the user's email")} />
+          </FormItem>
+          <FormItem
+            name="roles"
+            label={t('Roles')}
+            rules={[{ required: true, message: t('Role is required') }]}
+          >
+            <Select
+              name="roles"
+              mode="multiple"
+              placeholder={t('Select roles')}
+              options={roles.map(role => ({
+                value: role.id,
+                label: role.name,
+              }))}
+              getPopupContainer={trigger =>
+                trigger.closest('.antd5-modal-content')
+              }
+            />
+          </FormItem>
+
+          {!isEditMode && (
+            <>
+              <FormItem
+                name="password"
+                label={t('Password')}
+                rules={[{ required: true, message: t('Password is required') 
}]}
+              >
+                <Input.Password
+                  name="password"
+                  placeholder="Enter the user's password"
+                />
+              </FormItem>
+              <FormItem
+                name="confirmPassword"
+                label={t('Confirm Password')}
+                dependencies={['password']}
+                rules={[
+                  {
+                    required: true,
+                    message: t('Please confirm your password'),
+                  },
+                  ({ getFieldValue }) => ({
+                    validator(_, value) {
+                      if (!value || getFieldValue('password') === value) {
+                        return Promise.resolve();
+                      }
+                      return Promise.reject(
+                        new Error(t('Passwords do not match!')),
+                      );
+                    },
+                  }),
+                ]}
+              >
+                <Input.Password
+                  name="confirmPassword"
+                  placeholder={t("Confirm the user's password")}
+                />
+              </FormItem>
+            </>
+          )}
+        </>
+      )}
+    </FormModal>
+  );
+}
+
+export const UserListAddModal = (
+  props: Omit<UserModalProps, 'isEditMode' | 'initialValues'>,
+) => <UserListModal {...props} isEditMode={false} />;
+
+export const UserListEditModal = (
+  props: Omit<UserModalProps, 'isEditMode'> & { user: UserObject },
+) => <UserListModal {...props} isEditMode />;
+
+export default UserListModal;
diff --git a/superset-frontend/src/components/Checkbox/Checkbox.tsx 
b/superset-frontend/src/features/users/types.ts
similarity index 50%
copy from superset-frontend/src/components/Checkbox/Checkbox.tsx
copy to superset-frontend/src/features/users/types.ts
index 941b97aa53..49535e6168 100644
--- a/superset-frontend/src/components/Checkbox/Checkbox.tsx
+++ b/superset-frontend/src/features/users/types.ts
@@ -16,43 +16,12 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { CSSProperties } from 'react';
-import { styled } from '@superset-ui/core';
-import { CheckboxChecked, CheckboxUnchecked } from 'src/components/Checkbox';
-
-export interface CheckboxProps {
-  checked: boolean;
-  onChange: (val?: boolean) => void;
-  style?: CSSProperties;
-  className?: string;
+export interface BaseUserListModalProps {
+  show: boolean;
+  onHide: () => void;
+  onSave: () => void;
 }
 
-const Styles = styled.span`
-  &,
-  & svg {
-    vertical-align: top;
-  }
-`;
-
-export default function Checkbox({
-  checked,
-  onChange,
-  style,
-  className,
-}: CheckboxProps) {
-  return (
-    <Styles
-      style={style}
-      onClick={() => {
-        onChange(!checked);
-      }}
-      role="checkbox"
-      tabIndex={0}
-      aria-checked={checked}
-      aria-label="Checkbox"
-      className={className || ''}
-    >
-      {checked ? <CheckboxChecked /> : <CheckboxUnchecked />}
-    </Styles>
-  );
-}
+export type FormValues = {
+  [key: string]: string | number | boolean | string[] | number[];
+};
diff --git a/superset-frontend/src/components/Checkbox/Checkbox.tsx 
b/superset-frontend/src/features/users/utils.ts
similarity index 50%
copy from superset-frontend/src/components/Checkbox/Checkbox.tsx
copy to superset-frontend/src/features/users/utils.ts
index 941b97aa53..f2b519e650 100644
--- a/superset-frontend/src/components/Checkbox/Checkbox.tsx
+++ b/superset-frontend/src/features/users/utils.ts
@@ -16,43 +16,28 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { CSSProperties } from 'react';
-import { styled } from '@superset-ui/core';
-import { CheckboxChecked, CheckboxUnchecked } from 'src/components/Checkbox';
+import { SupersetClient } from '@superset-ui/core';
+import { FormValues } from './types';
 
-export interface CheckboxProps {
-  checked: boolean;
-  onChange: (val?: boolean) => void;
-  style?: CSSProperties;
-  className?: string;
-}
-
-const Styles = styled.span`
-  &,
-  & svg {
-    vertical-align: top;
+export const createUser = async (values: FormValues) => {
+  const { confirmPassword, ...payload } = values;
+  if (payload.active == null) {
+    payload.active = false;
   }
-`;
+  await SupersetClient.post({
+    endpoint: '/api/v1/security/users/',
+    jsonPayload: { ...payload },
+  });
+};
+
+export const updateUser = async (user_Id: number, values: FormValues) => {
+  await SupersetClient.put({
+    endpoint: `/api/v1/security/users/${user_Id}`,
+    jsonPayload: { ...values },
+  });
+};
 
-export default function Checkbox({
-  checked,
-  onChange,
-  style,
-  className,
-}: CheckboxProps) {
-  return (
-    <Styles
-      style={style}
-      onClick={() => {
-        onChange(!checked);
-      }}
-      role="checkbox"
-      tabIndex={0}
-      aria-checked={checked}
-      aria-label="Checkbox"
-      className={className || ''}
-    >
-      {checked ? <CheckboxChecked /> : <CheckboxUnchecked />}
-    </Styles>
-  );
-}
+export const deleteUser = async (userId: number) =>
+  SupersetClient.delete({
+    endpoint: `/api/v1/security/users/${userId}`,
+  });
diff --git a/superset-frontend/src/pages/UsersList/UsersList.test.tsx 
b/superset-frontend/src/pages/UsersList/UsersList.test.tsx
new file mode 100644
index 0000000000..361fe2bc00
--- /dev/null
+++ b/superset-frontend/src/pages/UsersList/UsersList.test.tsx
@@ -0,0 +1,192 @@
+/**
+ * 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 UsersList from './index';
+
+const mockStore = configureStore([thunk]);
+const store = mockStore({});
+
+const rolesEndpoint = 'glob:*/security/roles/?*';
+const usersEndpoint = 'glob:*/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 mockUsers = [...new Array(5)].map((_, i) => ({
+  active: true,
+  changed_by: { id: 1 },
+  changed_on: new Date(2025, 2, 25, 11, 4, 32 + i).toISOString(),
+  created_by: { id: 1 },
+  created_on: new Date(2025, 2, 25, 11, 4, 32 + i).toISOString(),
+  email: `user${i}@example.com`,
+  fail_login_count: null,
+  first_name: `User${i}`,
+  id: i + 1,
+  last_login: null,
+  last_name: `Test${i}`,
+  login_count: null,
+  roles: [{ id: i % 3, name: `role ${i % 3}` }],
+  username: `user${i}`,
+}));
+
+fetchMock.get(usersEndpoint, {
+  ids: [2, 0, 1, 3, 4],
+  result: mockUsers,
+  count: 5,
+});
+
+fetchMock.get(rolesEndpoint, {
+  ids: [2, 0, 1],
+  result: mockRoles,
+  count: 3,
+});
+
+jest.mock('src/dashboard/util/permissionUtils', () => ({
+  ...jest.requireActual('src/dashboard/util/permissionUtils'),
+  isUserAdmin: jest.fn(() => true),
+}));
+
+const mockUser = {
+  userId: 1,
+  firstName: 'Admin',
+  lastName: 'User',
+  roles: [
+    {
+      id: 1,
+      name: 'Admin',
+    },
+  ],
+};
+
+describe('UsersList', () => {
+  async function renderAndWait() {
+    const mounted = act(async () => {
+      const mockedProps = {};
+      render(
+        <MemoryRouter>
+          <QueryParamProvider>
+            <UsersList user={mockUser} {...mockedProps} />
+          </QueryParamProvider>
+        </MemoryRouter>,
+        { useRedux: true, store },
+      );
+    });
+    return mounted;
+  }
+  beforeEach(() => {
+    fetchMock.resetHistory();
+  });
+
+  it('renders', async () => {
+    await renderAndWait();
+    expect(await screen.findByText('List Users')).toBeInTheDocument();
+  });
+
+  it('fetches users on load', async () => {
+    await renderAndWait();
+    await waitFor(() => {
+      const calls = fetchMock.calls(usersEndpoint);
+      expect(calls.length).toBeGreaterThan(0);
+    });
+  });
+
+  it('fetches roles on load', async () => {
+    await renderAndWait();
+    await waitFor(() => {
+      const calls = fetchMock.calls(rolesEndpoint);
+      expect(calls.length).toBeGreaterThan(0);
+    });
+  });
+
+  it('renders filters options', async () => {
+    await renderAndWait();
+
+    const submenu = screen.queryAllByTestId('filters-select')[0];
+    expect(within(submenu).getByText(/first name/i)).toBeInTheDocument();
+    expect(within(submenu).getByText(/last name/i)).toBeInTheDocument();
+    expect(within(submenu).getByText(/email/i)).toBeInTheDocument();
+    expect(within(submenu).getByText(/username/i)).toBeInTheDocument();
+    expect(within(submenu).getByText(/roles/i)).toBeInTheDocument();
+    expect(within(submenu).getByText(/is active?/i)).toBeInTheDocument();
+    expect(within(submenu).getByText(/created on/i)).toBeInTheDocument();
+    expect(within(submenu).getByText(/changed on/i)).toBeInTheDocument();
+    expect(within(submenu).getByText(/last login/i)).toBeInTheDocument();
+  });
+
+  it('renders correct list columns', async () => {
+    await renderAndWait();
+
+    const table = screen.getByRole('table');
+    expect(table).toBeInTheDocument();
+
+    const fnameColumn = await within(table).findByText('First name');
+    const lnameColumn = await within(table).findByText('Last name');
+    const usernameColumn = await within(table).findByText('Username');
+    const emailColumn = await within(table).findByText('Email');
+    const rolesColumn = await within(table).findByText('Roles');
+    const actionsColumn = await within(table).findByText('Actions');
+    const activeColumn = await within(table).findByText('Is active?');
+
+    expect(fnameColumn).toBeInTheDocument();
+    expect(lnameColumn).toBeInTheDocument();
+    expect(usernameColumn).toBeInTheDocument();
+    expect(emailColumn).toBeInTheDocument();
+    expect(rolesColumn).toBeInTheDocument();
+    expect(activeColumn).toBeInTheDocument();
+    expect(actionsColumn).toBeInTheDocument();
+  });
+
+  it('opens add modal when Add User button is clicked', async () => {
+    await renderAndWait();
+
+    const addButton = screen.getByTestId('add-user-button');
+    fireEvent.click(addButton);
+
+    expect(screen.queryByTestId('Add User-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(
+      'user-list-edit-action',
+    )[0];
+    expect(editAction).toBeInTheDocument();
+    fireEvent.click(editAction);
+    expect(screen.queryByTestId('Edit User-modal')).toBeInTheDocument();
+  });
+});
diff --git a/superset-frontend/src/pages/UsersList/index.tsx 
b/superset-frontend/src/pages/UsersList/index.tsx
new file mode 100644
index 0000000000..ec6691bb38
--- /dev/null
+++ b/superset-frontend/src/pages/UsersList/index.tsx
@@ -0,0 +1,598 @@
+/**
+ * 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 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 { isUserAdmin } from 'src/dashboard/util/permissionUtils';
+import { Icons } from 'src/components/Icons';
+import {
+  UserListAddModal,
+  UserListEditModal,
+} from 'src/features/users/UserListModal';
+import { useToasts } from 'src/components/MessageToasts/withToasts';
+import { deleteUser } from 'src/features/users/utils';
+
+const PAGE_SIZE = 25;
+
+interface UsersListProps {
+  user: {
+    userId: string | number;
+    firstName: string;
+    lastName: string;
+    roles: object;
+  };
+}
+
+export type Role = {
+  id: number;
+  name: string;
+};
+
+export type UserObject = {
+  active: boolean;
+  changed_by: string | null;
+  changed_on: string;
+  created_by: string | null;
+  created_on: string;
+  email: string;
+  fail_login_count: number;
+  first_name: string;
+  id: number;
+  last_login: string;
+  last_name: string;
+  login_count: number;
+  roles: Role[];
+  username: string;
+};
+
+enum ModalType {
+  ADD = 'add',
+  EDIT = 'edit',
+}
+
+const isActiveOptions = [
+  {
+    label: 'Yes',
+    value: true,
+  },
+  {
+    label: 'No',
+    value: false,
+  },
+];
+
+function UsersList({ user }: UsersListProps) {
+  const theme = useTheme();
+  const { addDangerToast, addSuccessToast } = useToasts();
+  const {
+    state: {
+      loading,
+      resourceCount: usersCount,
+      resourceCollection: users,
+      bulkSelectEnabled,
+    },
+    fetchData,
+    refreshData,
+    toggleBulkSelect,
+  } = useListViewResource<UserObject>(
+    'security/users',
+    t('User'),
+    addDangerToast,
+  );
+  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 [currentUser, setCurrentUser] = useState<UserObject | null>(null);
+  const [userCurrentlyDeleting, setUserCurrentlyDeleting] =
+    useState<UserObject | null>(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const [roles, setRoles] = useState<Role[]>([]);
+  const loginCountStats = useMemo(() => {
+    if (!users || users.length === 0) return { min: 0, max: 0 };
+
+    const loginCounts = users.map(user => user.login_count);
+    return {
+      min: Math.min(...loginCounts),
+      max: Math.max(...loginCounts),
+    };
+  }, [users]);
+  const failLoginCountStats = useMemo(() => {
+    if (!users || users.length === 0) return { min: 0, max: 0 };
+
+    const failLoginCounts = users.map(user => user.fail_login_count);
+    return {
+      min: Math.min(...failLoginCounts),
+      max: Math.max(...failLoginCounts),
+    };
+  }, [users]);
+
+  const isAdmin = useMemo(() => isUserAdmin(user), [user]);
+
+  const fetchRoles = useCallback(async () => {
+    try {
+      const pageSize = 100;
+
+      const fetchPage = async (pageIndex: number) => {
+        const response = await SupersetClient.get({
+          endpoint: 
`api/v1/security/roles/?q=(page_size:${pageSize},page:${pageIndex})`,
+        });
+        return response.json;
+      };
+
+      const initialResponse = await fetchPage(0);
+      const totalRoles = initialResponse.count;
+      const firstPageResults = initialResponse.result;
+
+      if (pageSize >= totalRoles) {
+        setRoles(firstPageResults);
+        return;
+      }
+
+      const totalPages = Math.ceil(totalRoles / pageSize);
+
+      const roleRequests = Array.from({ length: totalPages - 1 }, (_, i) =>
+        fetchPage(i + 1),
+      );
+      const remainingResults = await Promise.all(roleRequests);
+
+      setRoles([
+        ...firstPageResults,
+        ...remainingResults.flatMap(res => res.result),
+      ]);
+    } catch (err) {
+      addDangerToast(t('Error while fetching roles'));
+    } finally {
+      setIsLoading(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    fetchRoles();
+  }, [fetchRoles]);
+
+  const handleUserDelete = async ({ id, username }: UserObject) => {
+    try {
+      await deleteUser(id);
+      refreshData();
+      setUserCurrentlyDeleting(null);
+      addSuccessToast(t('Deleted user: %s', username));
+    } catch (error) {
+      addDangerToast(t('There was an issue deleting %s', username));
+    }
+  };
+
+  const handleBulkUsersDelete = (usersToDelete: UserObject[]) => {
+    const deletedUserNames: string[] = [];
+
+    Promise.all(
+      usersToDelete.map(user =>
+        deleteUser(user.id)
+          .then(() => {
+            deletedUserNames.push(user.username);
+          })
+          .catch(err => {
+            addDangerToast(t('Error deleting %s', user.username));
+          }),
+      ),
+    )
+      .then(() => {
+        if (deletedUserNames.length > 0) {
+          addSuccessToast(t('Deleted users: %s', deletedUserNames.join(', ')));
+        }
+      })
+      .finally(() => {
+        refreshData();
+      });
+  };
+
+  const initialSort = [{ id: 'username', desc: true }];
+  const columns = useMemo(
+    () => [
+      {
+        accessor: 'first_name',
+        Header: t('First name'),
+        Cell: ({
+          row: {
+            original: { first_name },
+          },
+        }: any) => <span>{first_name}</span>,
+      },
+      {
+        accessor: 'last_name',
+        Header: t('Last name'),
+        Cell: ({
+          row: {
+            original: { last_name },
+          },
+        }: any) => <span>{last_name}</span>,
+      },
+      {
+        accessor: 'username',
+        Header: t('Username'),
+        Cell: ({
+          row: {
+            original: { username },
+          },
+        }: any) => <span>{username}</span>,
+      },
+      {
+        accessor: 'email',
+        Header: t('Email'),
+        Cell: ({
+          row: {
+            original: { email },
+          },
+        }: any) => <span>{email}</span>,
+      },
+      {
+        accessor: 'active',
+        Header: t('Is active?'),
+        Cell: ({
+          row: {
+            original: { active },
+          },
+        }: any) => <span>{active ? 'Yes' : 'No'}</span>,
+      },
+      {
+        accessor: 'roles',
+        Header: t('Roles'),
+        Cell: ({
+          row: {
+            original: { roles },
+          },
+        }: any) => (
+          <span>{roles.map((role: Role) => role.name).join(', ')}</span>
+        ),
+        disableSortBy: true,
+      },
+      {
+        accessor: 'login_count',
+        Header: t('Login count'),
+        hidden: true,
+        Cell: ({ row: { original } }: any) => original.login_count,
+      },
+      {
+        accessor: 'fail_login_count',
+        Header: t('Fail login count'),
+        hidden: true,
+        Cell: ({ row: { original } }: any) => original.fail_login_count,
+      },
+      {
+        accessor: 'created_on',
+        Header: t('Created on'),
+        hidden: true,
+        Cell: ({
+          row: {
+            original: { created_on },
+          },
+        }: any) => created_on,
+      },
+      {
+        accessor: 'changed_on',
+        Header: t('Changed on'),
+        hidden: true,
+        Cell: ({
+          row: {
+            original: { changed_on },
+          },
+        }: any) => changed_on,
+      },
+      {
+        accessor: 'last_login',
+        Header: t('Last login'),
+        hidden: true,
+        Cell: ({
+          row: {
+            original: { last_login },
+          },
+        }: any) => last_login,
+      },
+      {
+        Cell: ({ row: { original } }: any) => {
+          const handleEdit = () => {
+            setCurrentUser(original);
+            openModal(ModalType.EDIT);
+          };
+          const handleDelete = () => setUserCurrentlyDeleting(original);
+          const actions = isAdmin
+            ? [
+                {
+                  label: 'user-list-edit-action',
+                  tooltip: t('Edit user'),
+                  placement: 'bottom',
+                  icon: 'EditOutlined',
+                  onClick: handleEdit,
+                },
+                {
+                  label: 'role-list-delete-action',
+                  tooltip: t('Delete user'),
+                  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('User')}
+          </>
+        ),
+        buttonStyle: 'primary',
+        onClick: () => {
+          openModal(ModalType.ADD);
+        },
+        loading: isLoading,
+        'data-test': 'add-user-button',
+      },
+      {
+        name: t('Bulk select'),
+        onClick: toggleBulkSelect,
+        buttonStyle: 'secondary',
+      },
+    );
+  }
+
+  const filters: Filters = useMemo(
+    () => [
+      {
+        Header: t('First name'),
+        key: 'first_name',
+        id: 'first_name',
+        input: 'search',
+        operator: FilterOperator.Contains,
+      },
+      {
+        Header: t('Last name'),
+        key: 'last_name',
+        id: 'last_name',
+        input: 'search',
+        operator: FilterOperator.Contains,
+      },
+      {
+        Header: t('Username'),
+        key: 'username',
+        id: 'username',
+        input: 'search',
+        operator: FilterOperator.Contains,
+      },
+      {
+        Header: t('Email'),
+        key: 'email',
+        id: 'email',
+        input: 'search',
+        operator: FilterOperator.Contains,
+      },
+      {
+        Header: t('Is active?'),
+        key: 'active',
+        id: 'active',
+        input: 'select',
+        operator: FilterOperator.Equals,
+        unfilteredLabel: t('All'),
+        selects: isActiveOptions?.map(option => ({
+          label: option.label,
+          value: option.value,
+        })),
+        loading: isLoading,
+      },
+      {
+        Header: t('Roles'),
+        key: 'roles',
+        id: 'roles',
+        input: 'select',
+        operator: FilterOperator.RelationManyMany,
+        unfilteredLabel: t('All'),
+        selects: roles?.map(role => ({
+          label: role.name,
+          value: role.id,
+        })),
+        loading: isLoading,
+      },
+      {
+        Header: t('Created on'),
+        key: 'created_on',
+        id: 'created_on',
+        input: 'datetime_range',
+        operator: FilterOperator.Between,
+        dateFilterValueType: 'iso',
+      },
+      {
+        Header: t('Changed on'),
+        key: 'changed_on',
+        id: 'changed_on',
+        input: 'datetime_range',
+        operator: FilterOperator.Between,
+        dateFilterValueType: 'iso',
+      },
+      {
+        Header: t('Last login'),
+        key: 'last_login',
+        id: 'last_login',
+        input: 'datetime_range',
+        operator: FilterOperator.Between,
+        dateFilterValueType: 'iso',
+      },
+      {
+        Header: t('Login count'),
+        key: 'login_count',
+        id: 'login_count',
+        input: 'numerical_range',
+        operator: FilterOperator.Between,
+        min: loginCountStats.min,
+        max: loginCountStats.max,
+      },
+      {
+        Header: t('Fail login count'),
+        key: 'fail_login_count',
+        id: 'fail_login_count',
+        input: 'numerical_range',
+        operator: FilterOperator.Between,
+      },
+    ],
+    [isLoading, roles, loginCountStats, failLoginCountStats],
+  );
+
+  const emptyState = {
+    title: t('No users 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('User')}
+        </>
+      ),
+    }),
+  };
+
+  return (
+    <>
+      <SubMenu name={t('List Users')} buttons={subMenuButtons} />
+      <UserListAddModal
+        onHide={() => closeModal(ModalType.ADD)}
+        show={modalState.add}
+        onSave={() => {
+          refreshData();
+          closeModal(ModalType.ADD);
+        }}
+        roles={roles}
+      />
+      {modalState.edit && currentUser && (
+        <UserListEditModal
+          user={currentUser}
+          show={modalState.edit}
+          onHide={() => closeModal(ModalType.EDIT)}
+          onSave={() => {
+            refreshData();
+            closeModal(ModalType.EDIT);
+          }}
+          roles={roles}
+        />
+      )}
+
+      {userCurrentlyDeleting && (
+        <DeleteModal
+          description={t('This action will permanently delete the user.')}
+          onConfirm={() => {
+            if (userCurrentlyDeleting) {
+              handleUserDelete(userCurrentlyDeleting);
+            }
+          }}
+          onHide={() => setUserCurrentlyDeleting(null)}
+          open
+          title={t('Delete User?')}
+        />
+      )}
+      <ConfirmStatusChange
+        title={t('Please confirm')}
+        description={t('Are you sure you want to delete the selected users?')}
+        onConfirm={handleBulkUsersDelete}
+      >
+        {confirmDelete => {
+          const bulkActions: ListViewProps['bulkActions'] = isAdmin
+            ? [
+                {
+                  key: 'delete',
+                  name: t('Delete'),
+                  onSelect: confirmDelete,
+                  type: 'danger',
+                },
+              ]
+            : [];
+
+          return (
+            <ListView<UserObject>
+              className="user-list-view"
+              columns={columns}
+              count={usersCount}
+              data={users}
+              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 UsersList;
diff --git a/superset-frontend/src/views/CRUD/hooks.ts 
b/superset-frontend/src/views/CRUD/hooks.ts
index f7b3c7eaf0..6439739df4 100644
--- a/superset-frontend/src/views/CRUD/hooks.ts
+++ b/superset-frontend/src/views/CRUD/hooks.ts
@@ -146,9 +146,11 @@ export function useListViewResource<D extends object = 
any>(
         },
         loading: true,
       });
-
       const filterExps = (baseFilters || [])
         .concat(filterValues)
+        .filter(
+          ({ value }) => value !== '' && value !== null && value !== undefined,
+        )
         .map(({ id, operator: opr, value }) => ({
           col: id,
           opr,
diff --git a/superset-frontend/src/views/routes.tsx 
b/superset-frontend/src/views/routes.tsx
index a9ade6b75e..521bbee0dd 100644
--- a/superset-frontend/src/views/routes.tsx
+++ b/superset-frontend/src/views/routes.tsx
@@ -17,7 +17,12 @@
  * under the License.
  */
 import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
-import { lazy, ComponentType, ComponentProps } from 'react';
+import {
+  lazy,
+  ComponentType,
+  ComponentProps,
+  LazyExoticComponent,
+} from 'react';
 import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
 import getBootstrapData from 'src/utils/getBootstrapData';
 
@@ -129,6 +134,10 @@ const RolesList = lazy(
   () => import(/* webpackChunkName: "RolesList" */ 'src/pages/RolesList'),
 );
 
+const UsersList: LazyExoticComponent<any> = lazy(
+  () => import(/* webpackChunkName: "UsersList" */ 'src/pages/UsersList'),
+);
+
 type Routes = {
   path: string;
   Component: ComponentType;
@@ -248,10 +257,16 @@ const user = getBootstrapData()?.user;
 const isAdmin = isUserAdmin(user);
 
 if (isAdmin) {
-  routes.push({
-    path: '/roles/',
-    Component: RolesList,
-  });
+  routes.push(
+    {
+      path: '/roles/',
+      Component: RolesList,
+    },
+    {
+      path: '/users/',
+      Component: UsersList,
+    },
+  );
 }
 
 const frontEndRoutes: Record<string, boolean> = routes
diff --git a/superset/initialization/__init__.py 
b/superset/initialization/__init__.py
index a00156fcff..38edd5ac6a 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -184,6 +184,7 @@ class SupersetAppInitializer:  # pylint: 
disable=too-many-public-methods
         from superset.views.sqllab import SqllabView
         from superset.views.tags import TagModelView, TagView
         from superset.views.users.api import CurrentUserRestApi, UserRestApi
+        from superset.views.users_list import UsersListView
 
         set_app_error_handlers(self.superset_app)
 
@@ -279,6 +280,14 @@ class SupersetAppInitializer:  # pylint: 
disable=too-many-public-methods
             icon="fa-lock",
         )
 
+        appbuilder.add_view(
+            UsersListView,
+            "List Users",
+            label=__("List Users"),
+            category="Security",
+            category_label=__("Security"),
+        )
+
         appbuilder.add_view(
             DynamicPluginsView,
             "Plugins",
diff --git a/superset/security/manager.py b/superset/security/manager.py
index 009e7f314c..962e0d9c5c 100644
--- a/superset/security/manager.py
+++ b/superset/security/manager.py
@@ -25,7 +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.apis import RoleApi, UserApi
 from flask_appbuilder.security.sqla.manager import SecurityManager
 from flask_appbuilder.security.sqla.models import (
     assoc_group_role,
@@ -41,7 +41,6 @@ from flask_appbuilder.security.sqla.models import (
 from flask_appbuilder.security.views import (
     PermissionModelView,
     PermissionViewModelView,
-    UserModelView,
     ViewMenuModelView,
 )
 from flask_appbuilder.widgets import ListWidget
@@ -138,17 +137,37 @@ class SupersetRoleApi(RoleApi):
         item.permissions = []
 
 
-UserModelView.list_widget = SupersetSecurityListWidget
+class SupersetUserApi(UserApi):
+    """
+    Overriding the UserApi to be able to delete users
+    """
+
+    search_columns = [
+        "id",
+        "roles",
+        "first_name",
+        "last_name",
+        "username",
+        "active",
+        "email",
+        "last_login",
+        "login_count",
+        "fail_login_count",
+        "created_on",
+        "changed_on",
+    ]
+
+    def pre_delete(self, item: Model) -> None:
+        """
+        Overriding this method to be able to delete items when they have 
constraints
+        """
+        item.roles = []
+
+
 PermissionViewModelView.list_widget = SupersetSecurityListWidget
 PermissionModelView.list_widget = SupersetSecurityListWidget
 
 # Limiting routes on FAB model views
-UserModelView.include_route_methods = RouteMethod.CRUD_SET | {
-    RouteMethod.ACTION,
-    RouteMethod.API_READ,
-    RouteMethod.ACTION_POST,
-    "userinfo",
-}
 PermissionViewModelView.include_route_methods = {RouteMethod.LIST}
 PermissionModelView.include_route_methods = {RouteMethod.LIST}
 ViewMenuModelView.include_route_methods = {RouteMethod.LIST}
@@ -225,6 +244,7 @@ class SupersetSecurityManager(  # pylint: 
disable=too-many-public-methods
     READ_ONLY_MODEL_VIEWS = {"Database", "DynamicPlugin"}
 
     role_api = SupersetRoleApi
+    user_api = SupersetUserApi
 
     USER_MODEL_VIEWS = {
         "RegisterUserModelView",
@@ -246,6 +266,7 @@ class SupersetSecurityManager(  # pylint: 
disable=too-many-public-methods
         "Action Log",
         "Log",
         "List Users",
+        "UsersListView",
         "List Roles",
         "List Groups",
         "ResetPasswordView",
@@ -2767,10 +2788,9 @@ class SupersetSecurityManager(  # pylint: 
disable=too-many-public-methods
         super().register_views()
 
         for view in list(self.appbuilder.baseviews):
-            if (
-                isinstance(view, self.rolemodelview.__class__)
-                and getattr(view, "route_base", None) == "/roles"
-            ):
+            if isinstance(view, self.rolemodelview.__class__) and getattr(
+                view, "route_base", None
+            ) in ["/roles", "/users"]:
                 self.appbuilder.baseviews.remove(view)
 
         security_menu = next(
@@ -2778,5 +2798,5 @@ class SupersetSecurityManager(  # pylint: 
disable=too-many-public-methods
         )
         if security_menu:
             for item in list(security_menu.childs):
-                if item.name == "List Roles":
+                if item.name in ["List Roles", "List Users"]:
                     security_menu.childs.remove(item)
diff --git a/tests/unit_tests/security/api_test.py 
b/superset/views/users_list.py
similarity index 59%
copy from tests/unit_tests/security/api_test.py
copy to superset/views/users_list.py
index faeec96f55..42f93fce9f 100644
--- a/tests/unit_tests/security/api_test.py
+++ b/superset/views/users_list.py
@@ -14,27 +14,21 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-import pytest
+from flask_appbuilder import permission_name
+from flask_appbuilder.api import expose
+from flask_appbuilder.security.decorators import has_access
 
-from superset.extensions import csrf
+from superset.superset_typing import FlaskResponse
 
+from .base import BaseSupersetView
 
[email protected](
-    "app",
-    [{"WTF_CSRF_ENABLED": True}],
-    indirect=True,
-)
-def test_csrf_not_exempt(app_context: None) -> None:
-    """
-    Test that REST API is not exempt from CSRF.
-    """
-    assert {blueprint.name for blueprint in csrf._exempt_blueprints} == {
-        "MenuApi",
-        "SecurityApi",
-        "OpenApi",
-        "PermissionViewMenuApi",
-        "SupersetRoleApi",
-        "UserApi",
-        "PermissionApi",
-        "ViewMenuApi",
-    }
+
+class UsersListView(BaseSupersetView):
+    route_base = "/"
+    class_permission_name = "security"
+
+    @expose("/users/")
+    @has_access
+    @permission_name("read")
+    def list(self) -> FlaskResponse:
+        return super().render_app_template()
diff --git a/tests/unit_tests/security/api_test.py 
b/tests/unit_tests/security/api_test.py
index faeec96f55..39ac115c8f 100644
--- a/tests/unit_tests/security/api_test.py
+++ b/tests/unit_tests/security/api_test.py
@@ -34,7 +34,7 @@ def test_csrf_not_exempt(app_context: None) -> None:
         "OpenApi",
         "PermissionViewMenuApi",
         "SupersetRoleApi",
-        "UserApi",
+        "SupersetUserApi",
         "PermissionApi",
         "ViewMenuApi",
     }

Reply via email to