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",
}