mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
feat(List Groups): Migrate List Groups FAB to React (#33301)
This commit is contained in:
@@ -204,3 +204,8 @@ export enum FilterPlugins {
|
||||
TimeColumn = 'filter_timecolumn',
|
||||
TimeGrain = 'filter_timegrain',
|
||||
}
|
||||
|
||||
export enum Actions {
|
||||
CREATE = 'create',
|
||||
UPDATE = 'update',
|
||||
}
|
||||
|
||||
164
superset-frontend/src/features/groups/GroupListModal.tsx
Normal file
164
superset-frontend/src/features/groups/GroupListModal.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 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 Select from 'src/components/Select/Select';
|
||||
import { GroupObject, Role } from 'src/pages/GroupsList';
|
||||
import { Actions } from 'src/constants';
|
||||
import AsyncSelect from 'src/components/Select/AsyncSelect';
|
||||
import { BaseGroupListModalProps, FormValues } from './types';
|
||||
import { createGroup, fetchUserOptions, updateGroup } from './utils';
|
||||
|
||||
export interface GroupModalProps extends BaseGroupListModalProps {
|
||||
roles: Role[];
|
||||
isEditMode?: boolean;
|
||||
group?: GroupObject;
|
||||
}
|
||||
|
||||
function GroupListModal({
|
||||
show,
|
||||
onHide,
|
||||
onSave,
|
||||
roles,
|
||||
isEditMode = false,
|
||||
group,
|
||||
}: GroupModalProps) {
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const handleFormSubmit = async (values: FormValues) => {
|
||||
const handleError = async (
|
||||
err: Response,
|
||||
action: Actions.CREATE | Actions.UPDATE,
|
||||
) => {
|
||||
let errorMessage =
|
||||
action === Actions.CREATE
|
||||
? t('There was an error creating the group. Please, try again.')
|
||||
: t('There was an error updating the group. Please, try again.');
|
||||
|
||||
if (err.status === 422) {
|
||||
const errorData = await err.json();
|
||||
const detail = errorData?.message || '';
|
||||
|
||||
if (detail.includes('duplicate key value')) {
|
||||
if (detail.includes('ab_group_name_key')) {
|
||||
errorMessage = t(
|
||||
'This name is already taken. Please choose another one.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addDangerToast(errorMessage);
|
||||
throw err;
|
||||
};
|
||||
|
||||
if (isEditMode) {
|
||||
if (!group) {
|
||||
throw new Error('Group is required in edit mode');
|
||||
}
|
||||
try {
|
||||
await updateGroup(group.id, values);
|
||||
addSuccessToast(t('The group has been updated successfully.'));
|
||||
} catch (err) {
|
||||
await handleError(err, Actions.UPDATE);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await createGroup(values);
|
||||
addSuccessToast(t('The group has been created successfully.'));
|
||||
} catch (err) {
|
||||
await handleError(err, Actions.CREATE);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const requiredFields = ['name'];
|
||||
const initialValues = {
|
||||
...group,
|
||||
roles: group?.roles?.map(role => role.id) || [],
|
||||
users:
|
||||
group?.users?.map(user => ({
|
||||
value: user.id,
|
||||
label: user.username,
|
||||
})) || [],
|
||||
};
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
title={isEditMode ? t('Edit Group') : t('Add Group')}
|
||||
onSave={onSave}
|
||||
formSubmitHandler={handleFormSubmit}
|
||||
requiredFields={requiredFields}
|
||||
initialValues={initialValues}
|
||||
>
|
||||
<FormItem
|
||||
name="name"
|
||||
label={t('Name')}
|
||||
rules={[{ required: true, message: t('Name is required') }]}
|
||||
>
|
||||
<Input name="name" placeholder={t("Enter the group's name")} />
|
||||
</FormItem>
|
||||
<FormItem name="label" label={t('Label')}>
|
||||
<Input name="label" placeholder={t("Enter the group's label")} />
|
||||
</FormItem>
|
||||
<FormItem name="description" label={t('Description')}>
|
||||
<Input
|
||||
name="description"
|
||||
placeholder={t("Enter the group's description")}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem name="roles" label={t('Roles')}>
|
||||
<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>
|
||||
<FormItem name="users" label={t('Users')}>
|
||||
<AsyncSelect
|
||||
name="users"
|
||||
mode="multiple"
|
||||
placeholder={t('Select users')}
|
||||
options={(filterValue, page, pageSize) =>
|
||||
fetchUserOptions(filterValue, page, pageSize, addDangerToast)
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
export const GroupListAddModal = (
|
||||
props: Omit<GroupModalProps, 'isEditMode' | 'initialValues'>,
|
||||
) => <GroupListModal {...props} isEditMode={false} />;
|
||||
|
||||
export const GroupListEditModal = (
|
||||
props: Omit<GroupModalProps, 'isEditMode'> & { group: GroupObject },
|
||||
) => <GroupListModal {...props} isEditMode />;
|
||||
|
||||
export default GroupListModal;
|
||||
@@ -21,14 +21,22 @@ import Select from 'src/components/Select/Select';
|
||||
import { Input } from 'src/components/Input';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { FC } from 'react';
|
||||
import { FormattedPermission, UserObject } from './types';
|
||||
import { GroupObject } from 'src/pages/GroupsList';
|
||||
import AsyncSelect from 'src/components/Select/AsyncSelect';
|
||||
import { FormattedPermission } from './types';
|
||||
import { fetchUserOptions } from '../groups/utils';
|
||||
|
||||
interface PermissionsFieldProps {
|
||||
permissions: FormattedPermission[];
|
||||
}
|
||||
|
||||
interface GroupsFieldProps {
|
||||
groups: GroupObject[];
|
||||
}
|
||||
|
||||
interface UsersFieldProps {
|
||||
users: UserObject[];
|
||||
addDangerToast: (msg: string) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const RoleNameField = () => (
|
||||
@@ -58,13 +66,28 @@ export const PermissionsField: FC<PermissionsFieldProps> = ({
|
||||
</FormItem>
|
||||
);
|
||||
|
||||
export const UsersField: FC<UsersFieldProps> = ({ users }) => (
|
||||
export const UsersField = ({ addDangerToast, loading }: UsersFieldProps) => (
|
||||
<FormItem name="roleUsers" label={t('Users')}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
<AsyncSelect
|
||||
name="roleUsers"
|
||||
options={users.map(user => ({ label: user.username, value: user.id }))}
|
||||
data-test="users-select"
|
||||
mode="multiple"
|
||||
placeholder={t('Select users')}
|
||||
options={(filterValue, page, pageSize) =>
|
||||
fetchUserOptions(filterValue, page, pageSize, addDangerToast)
|
||||
}
|
||||
loading={loading}
|
||||
data-test="roles-select"
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
|
||||
export const GroupsField: FC<GroupsFieldProps> = ({ groups }) => (
|
||||
<FormItem name="roleGroups" label={t('Groups')}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
name="roleGroups"
|
||||
options={groups.map(group => ({ label: group.name, value: group.id }))}
|
||||
data-test="groups-select"
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
|
||||
@@ -43,9 +43,11 @@ function RoleListAddModal({
|
||||
await updateRolePermissions(roleResponse.id, values.rolePermissions);
|
||||
}
|
||||
|
||||
addSuccessToast(t('Role was successfully created!'));
|
||||
addSuccessToast(t('The role has been created successfully.'));
|
||||
} catch (err) {
|
||||
addDangerToast(t('Error while adding role!'));
|
||||
addDangerToast(
|
||||
t('There was an error creating the role. Please, try again.'),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('RoleListDuplicateModal', () => {
|
||||
name: 'Admin',
|
||||
permission_ids: [10, 20],
|
||||
user_ids: [1],
|
||||
group_ids: [],
|
||||
};
|
||||
|
||||
const mockProps = {
|
||||
|
||||
@@ -45,9 +45,11 @@ function RoleListDuplicateModal({
|
||||
if (permission_ids.length > 0) {
|
||||
await updateRolePermissions(roleResponse.id, permission_ids);
|
||||
}
|
||||
addSuccessToast(t('Role was successfully duplicated!'));
|
||||
addSuccessToast(t('The role has been duplicated successfully.'));
|
||||
} catch (err) {
|
||||
addDangerToast(t('Error while duplicating role!'));
|
||||
addDangerToast(
|
||||
t('There was an error duplicating the role. Please, try again.'),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import RoleListEditModal from './RoleListEditModal';
|
||||
import {
|
||||
updateRoleName,
|
||||
@@ -44,12 +45,24 @@ jest.mock('src/components/MessageToasts/withToasts', () => ({
|
||||
useToasts: () => mockToasts,
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/core', () => {
|
||||
const original = jest.requireActual('@superset-ui/core');
|
||||
return {
|
||||
...original,
|
||||
SupersetClient: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
t: (str: string) => str,
|
||||
};
|
||||
});
|
||||
|
||||
describe('RoleListEditModal', () => {
|
||||
const mockRole = {
|
||||
id: 1,
|
||||
name: 'Admin',
|
||||
permission_ids: [10, 20],
|
||||
user_ids: [5, 7],
|
||||
group_ids: [1, 2],
|
||||
};
|
||||
|
||||
const mockPermissions = [
|
||||
@@ -74,6 +87,17 @@ describe('RoleListEditModal', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const mockGroups = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Group A',
|
||||
label: 'Group A',
|
||||
description: 'Description A',
|
||||
roles: [],
|
||||
users: [],
|
||||
},
|
||||
];
|
||||
|
||||
const mockProps = {
|
||||
role: mockRole,
|
||||
show: true,
|
||||
@@ -81,6 +105,7 @@ describe('RoleListEditModal', () => {
|
||||
onSave: jest.fn(),
|
||||
permissions: mockPermissions,
|
||||
users: mockUsers,
|
||||
groups: mockGroups,
|
||||
};
|
||||
|
||||
it('renders modal with correct title and fields', () => {
|
||||
@@ -114,6 +139,36 @@ describe('RoleListEditModal', () => {
|
||||
});
|
||||
|
||||
it('calls update functions when save button is clicked', async () => {
|
||||
(SupersetClient.get as jest.Mock).mockImplementation(({ endpoint }) => {
|
||||
if (endpoint?.includes('/api/v1/security/users/')) {
|
||||
return Promise.resolve({
|
||||
json: {
|
||||
count: 2,
|
||||
result: [
|
||||
{
|
||||
id: 5,
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
username: 'johndoe',
|
||||
email: 'john@example.com',
|
||||
is_active: true,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
first_name: 'Jane',
|
||||
last_name: 'Smith',
|
||||
username: 'janesmith',
|
||||
email: 'jane@example.com',
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({ json: { count: 0, result: [] } });
|
||||
});
|
||||
|
||||
render(<RoleListEditModal {...mockProps} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('role-name-input'), {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { t } from '@superset-ui/core';
|
||||
import Tabs from 'src/components/Tabs';
|
||||
import { RoleObject } from 'src/pages/RolesList';
|
||||
@@ -25,13 +25,22 @@ import {
|
||||
BaseModalProps,
|
||||
FormattedPermission,
|
||||
RoleForm,
|
||||
UserObject,
|
||||
} from 'src/features/roles/types';
|
||||
import { CellProps } from 'react-table';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import FormModal from 'src/components/Modal/FormModal';
|
||||
import { PermissionsField, RoleNameField, UsersField } from './RoleFormItems';
|
||||
import { GroupObject } from 'src/pages/GroupsList';
|
||||
import { FormInstance } from 'src/components';
|
||||
import { fetchPaginatedData } from 'src/utils/fetchOptions';
|
||||
import { UserObject } from 'src/pages/UsersList';
|
||||
import {
|
||||
GroupsField,
|
||||
PermissionsField,
|
||||
RoleNameField,
|
||||
UsersField,
|
||||
} from './RoleFormItems';
|
||||
import {
|
||||
updateRoleGroups,
|
||||
updateRoleName,
|
||||
updateRolePermissions,
|
||||
updateRoleUsers,
|
||||
@@ -40,7 +49,7 @@ import {
|
||||
export interface RoleListEditModalProps extends BaseModalProps {
|
||||
role: RoleObject;
|
||||
permissions: FormattedPermission[];
|
||||
users: UserObject[];
|
||||
groups: GroupObject[];
|
||||
}
|
||||
|
||||
const roleTabs = {
|
||||
@@ -85,23 +94,67 @@ function RoleListEditModal({
|
||||
role,
|
||||
onSave,
|
||||
permissions,
|
||||
users,
|
||||
groups,
|
||||
}: RoleListEditModalProps) {
|
||||
const { id, name, permission_ids, user_ids } = role;
|
||||
const { id, name, permission_ids, user_ids, group_ids } = role;
|
||||
const [activeTabKey, setActiveTabKey] = useState(roleTabs.edit.key);
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const filteredUsers = users.filter(user =>
|
||||
user?.roles.some(role => role.id === id),
|
||||
);
|
||||
const [roleUsers, setRoleUsers] = useState<UserObject[]>([]);
|
||||
const [loadingRoleUsers, setLoadingRoleUsers] = useState(true);
|
||||
const formRef = useRef<FormInstance | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user_ids.length) {
|
||||
setRoleUsers([]);
|
||||
setLoadingRoleUsers(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const filters = [{ col: 'id', opr: 'in', value: user_ids }];
|
||||
|
||||
fetchPaginatedData({
|
||||
endpoint: `/api/v1/security/users/`,
|
||||
pageSize: 100,
|
||||
setData: setRoleUsers,
|
||||
filters,
|
||||
setLoadingState: (loading: boolean) => setLoadingRoleUsers(loading),
|
||||
loadingKey: 'roleUsers',
|
||||
addDangerToast,
|
||||
errorMessage: t('There was an error loading users.'),
|
||||
mapResult: (user: UserObject) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
}),
|
||||
});
|
||||
}, [user_ids]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadingRoleUsers && formRef.current && roleUsers.length >= 0) {
|
||||
const userOptions = roleUsers.map(user => ({
|
||||
value: user.id,
|
||||
label: user.username,
|
||||
}));
|
||||
|
||||
formRef.current.setFieldsValue({
|
||||
roleUsers: userOptions,
|
||||
});
|
||||
}
|
||||
}, [loadingRoleUsers, roleUsers]);
|
||||
|
||||
const handleFormSubmit = async (values: RoleForm) => {
|
||||
try {
|
||||
await updateRoleName(id, values.roleName);
|
||||
await updateRolePermissions(id, values.rolePermissions);
|
||||
await updateRoleUsers(id, values.roleUsers);
|
||||
addSuccessToast(t('Role successfully updated!'));
|
||||
const userIds = values.roleUsers?.map(user => user.value) || [];
|
||||
await Promise.all([
|
||||
updateRoleName(id, values.roleName),
|
||||
updateRolePermissions(id, values.rolePermissions),
|
||||
updateRoleUsers(id, userIds),
|
||||
updateRoleGroups(id, values.roleGroups),
|
||||
]);
|
||||
addSuccessToast(t('The role has been updated successfully.'));
|
||||
} catch (err) {
|
||||
addDangerToast(t('Error while updating role!'));
|
||||
addDangerToast(
|
||||
t('There was an error updating the role. Please, try again.'),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -109,7 +162,12 @@ function RoleListEditModal({
|
||||
const initialValues = {
|
||||
roleName: name,
|
||||
rolePermissions: permission_ids,
|
||||
roleUsers: user_ids,
|
||||
roleUsers:
|
||||
roleUsers?.map(user => ({
|
||||
value: user.id,
|
||||
label: user.username,
|
||||
})) || [],
|
||||
roleGroups: group_ids,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -123,29 +181,39 @@ function RoleListEditModal({
|
||||
bodyStyle={{ height: '400px' }}
|
||||
requiredFields={['roleName']}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeTabKey}
|
||||
onChange={activeKey => setActiveTabKey(activeKey)}
|
||||
>
|
||||
<Tabs.TabPane
|
||||
tab={roleTabs.edit.name}
|
||||
key={roleTabs.edit.key}
|
||||
forceRender
|
||||
>
|
||||
<>
|
||||
<RoleNameField />
|
||||
<PermissionsField permissions={permissions} />
|
||||
<UsersField users={users} />
|
||||
</>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={roleTabs.users.name} key={roleTabs.users.key}>
|
||||
<TableView
|
||||
columns={userColumns}
|
||||
data={filteredUsers}
|
||||
emptyWrapperType={EmptyWrapperType.Small}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
{(form: FormInstance) => {
|
||||
formRef.current = form;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={activeTabKey}
|
||||
onChange={activeKey => setActiveTabKey(activeKey)}
|
||||
>
|
||||
<Tabs.TabPane
|
||||
tab={roleTabs.edit.name}
|
||||
key={roleTabs.edit.key}
|
||||
forceRender
|
||||
>
|
||||
<>
|
||||
<RoleNameField />
|
||||
<PermissionsField permissions={permissions} />
|
||||
<UsersField
|
||||
addDangerToast={addDangerToast}
|
||||
loading={loadingRoleUsers}
|
||||
/>
|
||||
<GroupsField groups={groups} />
|
||||
</>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={roleTabs.users.name} key={roleTabs.users.key}>
|
||||
<TableView
|
||||
columns={userColumns}
|
||||
data={roleUsers}
|
||||
emptyWrapperType={EmptyWrapperType.Small}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
);
|
||||
}}
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ export type UserObject = {
|
||||
roles: Array<RoleInfo>;
|
||||
};
|
||||
|
||||
export type SelectOption = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type RoleInfo = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -56,7 +61,8 @@ export type RoleInfo = {
|
||||
export type RoleForm = {
|
||||
roleName: string;
|
||||
rolePermissions: number[];
|
||||
roleUsers: number[];
|
||||
roleUsers: SelectOption[];
|
||||
roleGroups: number[];
|
||||
};
|
||||
|
||||
export interface BaseModalProps {
|
||||
|
||||
@@ -40,6 +40,12 @@ export const updateRoleUsers = async (roleId: number, userIds: number[]) =>
|
||||
jsonPayload: { user_ids: userIds },
|
||||
});
|
||||
|
||||
export const updateRoleGroups = async (roleId: number, groupIds: number[]) =>
|
||||
SupersetClient.put({
|
||||
endpoint: `/api/v1/security/roles/${roleId}/groups`,
|
||||
jsonPayload: { group_ids: groupIds },
|
||||
});
|
||||
|
||||
export const updateRoleName = async (roleId: number, name: string) =>
|
||||
SupersetClient.put({
|
||||
endpoint: `/api/v1/security/roles/${roleId}`,
|
||||
|
||||
@@ -23,15 +23,17 @@ 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 { Group, Role, UserObject } from 'src/pages/UsersList';
|
||||
import { FormInstance } from 'src/components';
|
||||
import { Actions } from 'src/constants';
|
||||
import { BaseUserListModalProps, FormValues } from './types';
|
||||
import { createUser, updateUser } from './utils';
|
||||
import { createUser, updateUser, atLeastOneRoleOrGroup } from './utils';
|
||||
|
||||
export interface UserModalProps extends BaseUserListModalProps {
|
||||
roles: Role[];
|
||||
isEditMode?: boolean;
|
||||
user?: UserObject;
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
function UserListModal({
|
||||
@@ -41,14 +43,18 @@ function UserListModal({
|
||||
roles,
|
||||
isEditMode = false,
|
||||
user,
|
||||
groups,
|
||||
}: UserModalProps) {
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const handleFormSubmit = async (values: FormValues) => {
|
||||
const handleError = async (err: any, action: 'create' | 'update') => {
|
||||
const handleError = async (
|
||||
err: any,
|
||||
action: Actions.CREATE | Actions.UPDATE,
|
||||
) => {
|
||||
let errorMessage =
|
||||
action === 'create'
|
||||
? t('Error while adding user!')
|
||||
: t('Error while updating user!');
|
||||
action === Actions.CREATE
|
||||
? t('There was an error creating the user. Please, try again.')
|
||||
: t('There was an error updating the user. Please, try again.');
|
||||
|
||||
if (err.status === 422) {
|
||||
const errorData = await err.json();
|
||||
@@ -61,7 +67,7 @@ function UserListModal({
|
||||
);
|
||||
} else if (detail.includes('ab_user_email_key')) {
|
||||
errorMessage = t(
|
||||
'This email is already associated with an account.',
|
||||
'This email is already associated with an account. Please choose another one.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -77,35 +83,35 @@ function UserListModal({
|
||||
}
|
||||
try {
|
||||
await updateUser(user.id, values);
|
||||
addSuccessToast(t('User was successfully updated!'));
|
||||
addSuccessToast(t('The user has been updated successfully.'));
|
||||
} catch (err) {
|
||||
await handleError(err, 'update');
|
||||
await handleError(err, Actions.UPDATE);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await createUser(values);
|
||||
addSuccessToast(t('User was successfully created!'));
|
||||
addSuccessToast(t('The group has been created successfully.'));
|
||||
} catch (err) {
|
||||
await handleError(err, 'create');
|
||||
await handleError(err, Actions.CREATE);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const requiredFields = isEditMode
|
||||
? ['first_name', 'last_name', 'username', 'email', 'roles']
|
||||
? ['first_name', 'last_name', 'username', 'email']
|
||||
: [
|
||||
'first_name',
|
||||
'last_name',
|
||||
'username',
|
||||
'email',
|
||||
'password',
|
||||
'roles',
|
||||
'confirmPassword',
|
||||
];
|
||||
|
||||
const initialValues = {
|
||||
...user,
|
||||
roles: user?.roles.map(role => role.id) || [],
|
||||
roles: user?.roles?.map(role => role.id) || [],
|
||||
groups: user?.groups?.map(group => group.id) || [],
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -177,7 +183,8 @@ function UserListModal({
|
||||
<FormItem
|
||||
name="roles"
|
||||
label={t('Roles')}
|
||||
rules={[{ required: true, message: t('Role is required') }]}
|
||||
dependencies={['groups']}
|
||||
rules={[atLeastOneRoleOrGroup('groups')]}
|
||||
>
|
||||
<Select
|
||||
name="roles"
|
||||
@@ -192,7 +199,25 @@ function UserListModal({
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
name="groups"
|
||||
label={t('Groups')}
|
||||
dependencies={['roles']}
|
||||
rules={[atLeastOneRoleOrGroup('roles')]}
|
||||
>
|
||||
<Select
|
||||
name="groups"
|
||||
mode="multiple"
|
||||
placeholder={t('Select groups')}
|
||||
options={groups.map(group => ({
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
}))}
|
||||
getPopupContainer={trigger =>
|
||||
trigger.closest('.antd5-modal-content')
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
{!isEditMode && (
|
||||
<>
|
||||
<FormItem
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { SupersetClient, t } from '@superset-ui/core';
|
||||
import { SelectOption } from 'src/components/ListView';
|
||||
import { FormValues } from './types';
|
||||
|
||||
export const createUser = async (values: FormValues) => {
|
||||
@@ -41,3 +42,22 @@ export const deleteUser = async (userId: number) =>
|
||||
SupersetClient.delete({
|
||||
endpoint: `/api/v1/security/users/${userId}`,
|
||||
});
|
||||
|
||||
export const atLeastOneRoleOrGroup =
|
||||
(fieldToCheck: 'roles' | 'groups') =>
|
||||
({
|
||||
getFieldValue,
|
||||
}: {
|
||||
getFieldValue: (field: string) => Array<SelectOption>;
|
||||
}) => ({
|
||||
validator(_: Object, value: Array<SelectOption>) {
|
||||
const current = value || [];
|
||||
const other = getFieldValue(fieldToCheck) || [];
|
||||
if (current.length === 0 && other.length === 0) {
|
||||
return Promise.reject(
|
||||
new Error(t('Please select at least one role or group')),
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
165
superset-frontend/src/pages/GroupsList/GroupsList.test.tsx
Normal file
165
superset-frontend/src/pages/GroupsList/GroupsList.test.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 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 GroupsList from './index';
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
const mockUser = {
|
||||
userId: 1,
|
||||
firstName: 'Admin',
|
||||
lastName: 'User',
|
||||
roles: [{ id: 1, name: 'Admin' }],
|
||||
};
|
||||
|
||||
const rolesEndpoint = 'glob:*/security/roles/?*';
|
||||
const usersEndpoint = 'glob:*/security/users/?*';
|
||||
|
||||
const mockRoles = Array.from({ length: 3 }, (_, i) => ({
|
||||
id: i,
|
||||
name: `role ${i}`,
|
||||
}));
|
||||
|
||||
const mockUsers = Array.from({ length: 3 }, (_, i) => ({
|
||||
id: i,
|
||||
username: `user${i}`,
|
||||
}));
|
||||
|
||||
fetchMock.get(usersEndpoint, {
|
||||
result: mockUsers,
|
||||
count: 3,
|
||||
});
|
||||
|
||||
fetchMock.get(rolesEndpoint, {
|
||||
result: mockRoles,
|
||||
count: 3,
|
||||
});
|
||||
|
||||
jest.mock('src/dashboard/util/permissionUtils', () => ({
|
||||
...jest.requireActual('src/dashboard/util/permissionUtils'),
|
||||
isUserAdmin: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
describe('GroupsList', () => {
|
||||
const renderComponent = async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider>
|
||||
<GroupsList user={mockUser} />
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ useRedux: true, store },
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.resetHistory();
|
||||
});
|
||||
|
||||
it('renders the page', async () => {
|
||||
await renderComponent();
|
||||
expect(await screen.findByText('List Groups')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fetches roles on load', async () => {
|
||||
await renderComponent();
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls(rolesEndpoint).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders add group button and triggers modal', async () => {
|
||||
await renderComponent();
|
||||
const addButton = screen.getByTestId('add-group-button');
|
||||
fireEvent.click(addButton);
|
||||
expect(await screen.findByTestId('Add Group-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders actions column for admin', async () => {
|
||||
await renderComponent();
|
||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the filters correctly', async () => {
|
||||
await renderComponent();
|
||||
const filtersSelect = screen.getAllByTestId('filters-select')[0];
|
||||
|
||||
expect(within(filtersSelect).getByText(/name/i)).toBeInTheDocument();
|
||||
expect(within(filtersSelect).getByText(/label/i)).toBeInTheDocument();
|
||||
expect(within(filtersSelect).getByText(/description/i)).toBeInTheDocument();
|
||||
expect(within(filtersSelect).getByText(/roles/i)).toBeInTheDocument();
|
||||
expect(within(filtersSelect).getByText(/users/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct columns in the table', async () => {
|
||||
await renderComponent();
|
||||
const table = screen.getByRole('table');
|
||||
|
||||
expect(await within(table).findByText('Name')).toBeInTheDocument();
|
||||
expect(await within(table).findByText('Label')).toBeInTheDocument();
|
||||
expect(await within(table).findByText('Roles')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens add group modal on button click', async () => {
|
||||
await renderComponent();
|
||||
const addButton = screen.getByTestId('add-group-button');
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(await screen.findByTestId('Add Group-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens edit modal on edit button click', async () => {
|
||||
fetchMock.get('glob:*/security/groups/?*', {
|
||||
result: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Editors',
|
||||
label: 'editors',
|
||||
description: 'Group for editors',
|
||||
roles: mockRoles,
|
||||
users: mockUsers,
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
});
|
||||
|
||||
await renderComponent();
|
||||
|
||||
const editButtons = await screen.findAllByTestId('group-list-edit-action');
|
||||
fireEvent.click(editButtons[0]);
|
||||
|
||||
expect(await screen.findByTestId('Edit Group-modal')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
459
superset-frontend/src/pages/GroupsList/index.tsx
Normal file
459
superset-frontend/src/pages/GroupsList/index.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* 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, 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 {
|
||||
GroupListAddModal,
|
||||
GroupListEditModal,
|
||||
} from 'src/features/groups/GroupListModal';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { deleteGroup, fetchUserOptions } from 'src/features/groups/utils';
|
||||
import { fetchPaginatedData } from 'src/utils/fetchOptions';
|
||||
import { Tooltip } from 'src/components/Tooltip';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
interface GroupsListProps {
|
||||
user: {
|
||||
userId: string | number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
roles: object;
|
||||
};
|
||||
}
|
||||
|
||||
export type Role = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type GroupObject = {
|
||||
id: number;
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
roles: Role[];
|
||||
users: User[];
|
||||
};
|
||||
|
||||
enum ModalType {
|
||||
ADD = 'add',
|
||||
EDIT = 'edit',
|
||||
}
|
||||
|
||||
function GroupsList({ user }: GroupsListProps) {
|
||||
const theme = useTheme();
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const {
|
||||
state: {
|
||||
loading,
|
||||
resourceCount: groupsCount,
|
||||
resourceCollection: groups,
|
||||
bulkSelectEnabled,
|
||||
},
|
||||
fetchData,
|
||||
refreshData,
|
||||
toggleBulkSelect,
|
||||
} = useListViewResource<GroupObject>(
|
||||
'security/groups',
|
||||
t('Group'),
|
||||
addDangerToast,
|
||||
);
|
||||
const [modalState, setModalState] = useState({
|
||||
edit: false,
|
||||
add: false,
|
||||
});
|
||||
const openModal = (type: ModalType) =>
|
||||
setModalState(prev => ({ ...prev, [type]: true }));
|
||||
const closeModal = (type: ModalType) =>
|
||||
setModalState(prev => ({ ...prev, [type]: false }));
|
||||
|
||||
const [currentGroup, setCurrentGroup] = useState<GroupObject | null>(null);
|
||||
const [groupCurrentlyDeleting, setGroupCurrentlyDeleting] =
|
||||
useState<GroupObject | null>(null);
|
||||
const [loadingState, setLoadingState] = useState({
|
||||
roles: true,
|
||||
});
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
|
||||
const isAdmin = useMemo(() => isUserAdmin(user), [user]);
|
||||
|
||||
const fetchRoles = useCallback(() => {
|
||||
fetchPaginatedData({
|
||||
endpoint: '/api/v1/security/roles/',
|
||||
setData: setRoles,
|
||||
setLoadingState,
|
||||
loadingKey: 'roles',
|
||||
addDangerToast,
|
||||
errorMessage: t('Error while fetching roles'),
|
||||
});
|
||||
}, [addDangerToast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
}, [fetchRoles]);
|
||||
|
||||
const handleGroupDelete = async ({ id, name }: GroupObject) => {
|
||||
try {
|
||||
await deleteGroup(id);
|
||||
refreshData();
|
||||
setGroupCurrentlyDeleting(null);
|
||||
addSuccessToast(t('Deleted group: %s', name));
|
||||
} catch (error) {
|
||||
addDangerToast(t('There was an issue deleting %s', name));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkGroupsDelete = (groupsToDelete: GroupObject[]) => {
|
||||
const deletedGroupsNames: string[] = [];
|
||||
|
||||
Promise.all(
|
||||
groupsToDelete.map(group =>
|
||||
deleteGroup(group.id)
|
||||
.then(() => {
|
||||
deletedGroupsNames.push(group.name);
|
||||
})
|
||||
.catch(err => {
|
||||
addDangerToast(t('Error deleting %s', group.name));
|
||||
}),
|
||||
),
|
||||
)
|
||||
.then(() => {
|
||||
if (deletedGroupsNames.length > 0) {
|
||||
addSuccessToast(
|
||||
t('Deleted groups: %s', deletedGroupsNames.join(', ')),
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
refreshData();
|
||||
});
|
||||
};
|
||||
|
||||
const initialSort = [{ id: 'name', desc: true }];
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessor: 'name',
|
||||
id: 'name',
|
||||
Header: t('Name'),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { name },
|
||||
},
|
||||
}: any) => <span>{name}</span>,
|
||||
},
|
||||
{
|
||||
accessor: 'label',
|
||||
id: 'label',
|
||||
Header: t('Label'),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { label },
|
||||
},
|
||||
}: any) => <span>{label}</span>,
|
||||
},
|
||||
{
|
||||
accessor: 'description',
|
||||
id: 'description',
|
||||
Header: t('Description'),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { description },
|
||||
},
|
||||
}: any) => <span>{description}</span>,
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
accessor: 'roles',
|
||||
id: 'roles',
|
||||
Header: t('Roles'),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { roles },
|
||||
},
|
||||
}: any) => (
|
||||
<Tooltip
|
||||
title={
|
||||
roles?.map((role: Role) => role.name).join(', ') || t('No roles')
|
||||
}
|
||||
>
|
||||
<span>{roles?.map((role: Role) => role.name).join(', ')}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
disableSortBy: true,
|
||||
},
|
||||
{
|
||||
accessor: 'users',
|
||||
id: 'users',
|
||||
Header: t('Users'),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { users },
|
||||
},
|
||||
}: any) => (
|
||||
<span>{users?.map((user: User) => user.username).join(', ')}</span>
|
||||
),
|
||||
disableSortBy: true,
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
Cell: ({ row: { original } }: any) => {
|
||||
const handleEdit = () => {
|
||||
setCurrentGroup(original);
|
||||
openModal(ModalType.EDIT);
|
||||
};
|
||||
const handleDelete = () => setGroupCurrentlyDeleting(original);
|
||||
const actions = isAdmin
|
||||
? [
|
||||
{
|
||||
label: 'group-list-edit-action',
|
||||
tooltip: t('Edit group'),
|
||||
placement: 'bottom',
|
||||
icon: 'EditOutlined',
|
||||
onClick: handleEdit,
|
||||
},
|
||||
{
|
||||
label: 'group-list-delete-action',
|
||||
tooltip: t('Delete group'),
|
||||
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('Group')}
|
||||
</>
|
||||
),
|
||||
buttonStyle: 'primary',
|
||||
onClick: () => {
|
||||
openModal(ModalType.ADD);
|
||||
},
|
||||
loading: loadingState.roles,
|
||||
'data-test': 'add-group-button',
|
||||
},
|
||||
{
|
||||
name: t('Bulk select'),
|
||||
onClick: toggleBulkSelect,
|
||||
buttonStyle: 'secondary',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const filters: Filters = useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: t('Name'),
|
||||
key: 'name',
|
||||
id: 'name',
|
||||
input: 'search',
|
||||
operator: FilterOperator.Contains,
|
||||
},
|
||||
{
|
||||
Header: t('Label'),
|
||||
key: 'label',
|
||||
id: 'label',
|
||||
input: 'search',
|
||||
operator: FilterOperator.Contains,
|
||||
},
|
||||
{
|
||||
Header: t('Description'),
|
||||
key: 'description',
|
||||
id: 'description',
|
||||
input: 'search',
|
||||
operator: FilterOperator.Contains,
|
||||
},
|
||||
{
|
||||
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: loadingState.roles,
|
||||
},
|
||||
{
|
||||
Header: t('Users'),
|
||||
key: 'users',
|
||||
id: 'users',
|
||||
input: 'select',
|
||||
operator: FilterOperator.RelationManyMany,
|
||||
unfilteredLabel: t('All'),
|
||||
fetchSelects: async (filterValue, page, pageSize) =>
|
||||
fetchUserOptions(filterValue, page, pageSize, addDangerToast),
|
||||
},
|
||||
],
|
||||
[loadingState.roles, roles],
|
||||
);
|
||||
|
||||
const emptyState = {
|
||||
title: t('No groups 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('Group')}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubMenu name={t('List Groups')} buttons={subMenuButtons} />
|
||||
<GroupListAddModal
|
||||
onHide={() => closeModal(ModalType.ADD)}
|
||||
show={modalState.add}
|
||||
onSave={() => {
|
||||
refreshData();
|
||||
closeModal(ModalType.ADD);
|
||||
}}
|
||||
roles={roles}
|
||||
/>
|
||||
{modalState.edit && currentGroup && (
|
||||
<GroupListEditModal
|
||||
group={currentGroup}
|
||||
show={modalState.edit}
|
||||
onHide={() => closeModal(ModalType.EDIT)}
|
||||
onSave={() => {
|
||||
refreshData();
|
||||
closeModal(ModalType.EDIT);
|
||||
}}
|
||||
roles={roles}
|
||||
/>
|
||||
)}
|
||||
|
||||
{groupCurrentlyDeleting && (
|
||||
<DeleteModal
|
||||
description={t('This action will permanently delete the group.')}
|
||||
onConfirm={() => {
|
||||
if (groupCurrentlyDeleting) {
|
||||
handleGroupDelete(groupCurrentlyDeleting);
|
||||
}
|
||||
}}
|
||||
onHide={() => setGroupCurrentlyDeleting(null)}
|
||||
open
|
||||
title={t('Delete Group?')}
|
||||
/>
|
||||
)}
|
||||
<ConfirmStatusChange
|
||||
title={t('Please confirm')}
|
||||
description={t('Are you sure you want to delete the selected groups?')}
|
||||
onConfirm={handleBulkGroupsDelete}
|
||||
>
|
||||
{confirmDelete => {
|
||||
const bulkActions: ListViewProps['bulkActions'] = isAdmin
|
||||
? [
|
||||
{
|
||||
key: 'delete',
|
||||
name: t('Delete'),
|
||||
onSelect: confirmDelete,
|
||||
type: 'danger',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<ListView<GroupObject>
|
||||
className="group-list-view"
|
||||
columns={columns}
|
||||
count={groupsCount}
|
||||
data={groups}
|
||||
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 GroupsList;
|
||||
@@ -137,13 +137,11 @@ describe('RolesList', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches permissions and users on load', async () => {
|
||||
it('fetches permissions on load', async () => {
|
||||
await renderAndWait();
|
||||
await waitFor(() => {
|
||||
const permissionCalls = fetchMock.calls(permissionsEndpoint);
|
||||
const userCalls = fetchMock.calls(usersEndpoint);
|
||||
expect(permissionCalls.length).toBeGreaterThan(0);
|
||||
expect(userCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,7 +149,7 @@ describe('RolesList', () => {
|
||||
await renderAndWait();
|
||||
|
||||
const typeFilter = screen.queryAllByTestId('filters-select');
|
||||
expect(typeFilter).toHaveLength(3);
|
||||
expect(typeFilter).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('renders correct list columns', async () => {
|
||||
|
||||
@@ -33,13 +33,12 @@ import ListView, {
|
||||
} from 'src/components/ListView';
|
||||
import DeleteModal from 'src/components/DeleteModal';
|
||||
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
||||
import {
|
||||
FormattedPermission,
|
||||
PermissionResource,
|
||||
UserObject,
|
||||
} from 'src/features/roles/types';
|
||||
import { FormattedPermission, UserObject } from 'src/features/roles/types';
|
||||
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
import { Icons } from 'src/components/Icons';
|
||||
import { fetchPaginatedData } from 'src/utils/fetchOptions';
|
||||
import { fetchUserOptions } from 'src/features/groups/utils';
|
||||
import { GroupObject } from '../GroupsList';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -60,6 +59,7 @@ export type RoleObject = {
|
||||
permission_ids: number[];
|
||||
users?: Array<UserObject>;
|
||||
user_ids: number[];
|
||||
group_ids: number[];
|
||||
};
|
||||
|
||||
enum ModalType {
|
||||
@@ -100,108 +100,48 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
|
||||
const [roleCurrentlyDeleting, setRoleCurrentlyDeleting] =
|
||||
useState<RoleObject | null>(null);
|
||||
const [permissions, setPermissions] = useState<FormattedPermission[]>([]);
|
||||
const [users, setUsers] = useState<UserObject[]>([]);
|
||||
const [groups, setGroups] = useState<GroupObject[]>([]);
|
||||
const [loadingState, setLoadingState] = useState({
|
||||
permissions: true,
|
||||
users: true,
|
||||
groups: true,
|
||||
});
|
||||
|
||||
const isAdmin = useMemo(() => isUserAdmin(user), [user]);
|
||||
|
||||
const fetchPermissions = useCallback(async () => {
|
||||
try {
|
||||
const pageSize = 100;
|
||||
const fetchPermissions = useCallback(() => {
|
||||
fetchPaginatedData({
|
||||
endpoint: '/api/v1/security/permissions-resources/',
|
||||
setData: setPermissions,
|
||||
setLoadingState,
|
||||
loadingKey: 'permissions',
|
||||
addDangerToast,
|
||||
errorMessage: 'Error while fetching permissions',
|
||||
mapResult: ({ permission, view_menu, id }) => ({
|
||||
label: `${permission.name.replace(/_/g, ' ')} ${view_menu.name.replace(/_/g, ' ')}`,
|
||||
value: `${permission.name}__${view_menu.name}`,
|
||||
id,
|
||||
}),
|
||||
});
|
||||
}, [addDangerToast]);
|
||||
|
||||
const fetchPage = async (pageIndex: number) => {
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: `api/v1/security/permissions-resources/?q=(page_size:${pageSize},page:${pageIndex})`,
|
||||
});
|
||||
|
||||
return {
|
||||
count: response.json.count,
|
||||
results: response.json.result.map(
|
||||
({ permission, view_menu, id }: PermissionResource) => ({
|
||||
label: `${permission.name.replace(/_/g, ' ')} ${view_menu.name.replace(/_/g, ' ')}`,
|
||||
value: `${permission.name}__${view_menu.name}`,
|
||||
id,
|
||||
}),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const initialResponse = await fetchPage(0);
|
||||
const totalPermissions = initialResponse.count;
|
||||
const firstPageResults = initialResponse.results;
|
||||
|
||||
if (firstPageResults.length >= totalPermissions) {
|
||||
setPermissions(firstPageResults);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(totalPermissions / pageSize);
|
||||
|
||||
const permissionRequests = Array.from(
|
||||
{ length: totalPages - 1 },
|
||||
(_, i) => fetchPage(i + 1),
|
||||
);
|
||||
const remainingResults = await Promise.all(permissionRequests);
|
||||
|
||||
setPermissions([
|
||||
...firstPageResults,
|
||||
...remainingResults.flatMap(res => res.results),
|
||||
]);
|
||||
} catch (err) {
|
||||
addDangerToast(t('Error while fetching permissions'));
|
||||
} finally {
|
||||
setLoadingState(prev => ({ ...prev, permissions: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const pageSize = 100;
|
||||
|
||||
const fetchPage = async (pageIndex: number) => {
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: `api/v1/security/users/?q=(page_size:${pageSize},page:${pageIndex})`,
|
||||
});
|
||||
return response.json;
|
||||
};
|
||||
|
||||
const initialResponse = await fetchPage(0);
|
||||
const totalUsers = initialResponse.count;
|
||||
const firstPageResults = initialResponse.result;
|
||||
|
||||
if (pageSize >= totalUsers) {
|
||||
setUsers(firstPageResults);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(totalUsers / pageSize);
|
||||
|
||||
const userRequests = Array.from({ length: totalPages - 1 }, (_, i) =>
|
||||
fetchPage(i + 1),
|
||||
);
|
||||
const remainingResults = await Promise.all(userRequests);
|
||||
|
||||
setUsers([
|
||||
...firstPageResults,
|
||||
...remainingResults.flatMap(res => res.result),
|
||||
]);
|
||||
} catch (err) {
|
||||
addDangerToast(t('Error while fetching users'));
|
||||
} finally {
|
||||
setLoadingState(prev => ({ ...prev, users: false }));
|
||||
}
|
||||
}, []);
|
||||
const fetchGroups = useCallback(() => {
|
||||
fetchPaginatedData({
|
||||
endpoint: '/api/v1/security/groups/',
|
||||
setData: setGroups,
|
||||
setLoadingState,
|
||||
loadingKey: 'groups',
|
||||
addDangerToast,
|
||||
errorMessage: t('Error while fetching groups'),
|
||||
});
|
||||
}, [addDangerToast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPermissions();
|
||||
}, [fetchPermissions]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
fetchGroups();
|
||||
}, [fetchGroups]);
|
||||
|
||||
const handleRoleDelete = async ({ id, name }: RoleObject) => {
|
||||
try {
|
||||
@@ -246,6 +186,7 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
|
||||
() => [
|
||||
{
|
||||
accessor: 'name',
|
||||
id: 'name',
|
||||
Header: t('Name'),
|
||||
Cell: ({
|
||||
row: {
|
||||
@@ -255,12 +196,21 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
|
||||
},
|
||||
{
|
||||
accessor: 'user_ids',
|
||||
id: 'user_ids',
|
||||
Header: t('Users'),
|
||||
hidden: true,
|
||||
Cell: ({ row: { original } }: any) => original.user_ids.join(', '),
|
||||
},
|
||||
{
|
||||
accessor: 'group_ids',
|
||||
id: 'group_ids',
|
||||
Header: t('Groups'),
|
||||
hidden: true,
|
||||
Cell: ({ row: { original } }: any) => original.groups_ids.join(', '),
|
||||
},
|
||||
{
|
||||
accessor: 'permission_ids',
|
||||
id: 'permission_ids',
|
||||
Header: t('Permissions'),
|
||||
hidden: true,
|
||||
Cell: ({ row: { original } }: any) =>
|
||||
@@ -365,11 +315,8 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
|
||||
input: 'select',
|
||||
operator: FilterOperator.RelationOneMany,
|
||||
unfilteredLabel: t('All'),
|
||||
selects: users?.map(user => ({
|
||||
label: user.username,
|
||||
value: user.id,
|
||||
})),
|
||||
loading: loadingState.users,
|
||||
fetchSelects: async (filterValue, page, pageSize) =>
|
||||
fetchUserOptions(filterValue, page, pageSize, addDangerToast),
|
||||
},
|
||||
{
|
||||
Header: t('Permissions'),
|
||||
@@ -384,8 +331,21 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
|
||||
})),
|
||||
loading: loadingState.permissions,
|
||||
},
|
||||
{
|
||||
Header: t('Groups'),
|
||||
key: 'group_ids',
|
||||
id: 'group_ids',
|
||||
input: 'select',
|
||||
operator: FilterOperator.RelationOneMany,
|
||||
unfilteredLabel: t('All'),
|
||||
selects: groups?.map(group => ({
|
||||
label: group.name,
|
||||
value: group.id,
|
||||
})),
|
||||
loading: loadingState.groups,
|
||||
},
|
||||
],
|
||||
[permissions, users, loadingState.users, loadingState.permissions],
|
||||
[permissions, groups, loadingState.groups, loadingState.permissions],
|
||||
);
|
||||
|
||||
const emptyState = {
|
||||
@@ -431,10 +391,9 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
|
||||
onSave={() => {
|
||||
refreshData();
|
||||
closeModal(ModalType.EDIT);
|
||||
fetchUsers();
|
||||
}}
|
||||
permissions={permissions}
|
||||
users={users}
|
||||
groups={groups}
|
||||
/>
|
||||
)}
|
||||
{modalState.duplicate && currentRole && (
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { css, t, SupersetClient, useTheme } from '@superset-ui/core';
|
||||
import { css, t, 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';
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
} from 'src/features/users/UserListModal';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { deleteUser } from 'src/features/users/utils';
|
||||
import { fetchPaginatedData } from 'src/utils/fetchOptions';
|
||||
import { Tooltip } from 'src/components/Tooltip';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -54,6 +56,11 @@ export type Role = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type Group = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type UserObject = {
|
||||
active: boolean;
|
||||
changed_by: string | null;
|
||||
@@ -69,6 +76,7 @@ export type UserObject = {
|
||||
login_count: number;
|
||||
roles: Role[];
|
||||
username: string;
|
||||
groups: Group[];
|
||||
};
|
||||
|
||||
enum ModalType {
|
||||
@@ -108,7 +116,6 @@ function UsersList({ user }: UsersListProps) {
|
||||
const [modalState, setModalState] = useState({
|
||||
edit: false,
|
||||
add: false,
|
||||
duplicate: false,
|
||||
});
|
||||
const openModal = (type: ModalType) =>
|
||||
setModalState(prev => ({ ...prev, [type]: true }));
|
||||
@@ -118,8 +125,12 @@ function UsersList({ user }: UsersListProps) {
|
||||
const [currentUser, setCurrentUser] = useState<UserObject | null>(null);
|
||||
const [userCurrentlyDeleting, setUserCurrentlyDeleting] =
|
||||
useState<UserObject | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadingState, setLoadingState] = useState({
|
||||
roles: true,
|
||||
groups: true,
|
||||
});
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const loginCountStats = useMemo(() => {
|
||||
if (!users || users.length === 0) return { min: 0, max: 0 };
|
||||
|
||||
@@ -141,48 +152,36 @@ function UsersList({ user }: UsersListProps) {
|
||||
|
||||
const isAdmin = useMemo(() => isUserAdmin(user), [user]);
|
||||
|
||||
const fetchRoles = useCallback(async () => {
|
||||
try {
|
||||
const pageSize = 100;
|
||||
const fetchRoles = useCallback(() => {
|
||||
fetchPaginatedData({
|
||||
endpoint: '/api/v1/security/roles/',
|
||||
setData: setRoles,
|
||||
setLoadingState,
|
||||
loadingKey: 'roles',
|
||||
addDangerToast,
|
||||
errorMessage: t('Error while fetching roles'),
|
||||
});
|
||||
}, [addDangerToast]);
|
||||
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
const fetchGroups = useCallback(() => {
|
||||
fetchPaginatedData({
|
||||
endpoint: '/api/v1/security/groups/',
|
||||
setData: setGroups,
|
||||
setLoadingState,
|
||||
loadingKey: 'groups',
|
||||
addDangerToast,
|
||||
errorMessage: t('Error while fetching groups'),
|
||||
});
|
||||
}, [addDangerToast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
}, [fetchRoles]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroups();
|
||||
}, [fetchGroups]);
|
||||
|
||||
const handleUserDelete = async ({ id, username }: UserObject) => {
|
||||
try {
|
||||
await deleteUser(id);
|
||||
@@ -224,6 +223,7 @@ function UsersList({ user }: UsersListProps) {
|
||||
{
|
||||
accessor: 'first_name',
|
||||
Header: t('First name'),
|
||||
id: 'first_name',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { first_name },
|
||||
@@ -233,6 +233,7 @@ function UsersList({ user }: UsersListProps) {
|
||||
{
|
||||
accessor: 'last_name',
|
||||
Header: t('Last name'),
|
||||
id: 'last_name',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { last_name },
|
||||
@@ -242,6 +243,7 @@ function UsersList({ user }: UsersListProps) {
|
||||
{
|
||||
accessor: 'username',
|
||||
Header: t('Username'),
|
||||
id: 'username',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { username },
|
||||
@@ -251,6 +253,7 @@ function UsersList({ user }: UsersListProps) {
|
||||
{
|
||||
accessor: 'email',
|
||||
Header: t('Email'),
|
||||
id: 'email',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { email },
|
||||
@@ -260,6 +263,7 @@ function UsersList({ user }: UsersListProps) {
|
||||
{
|
||||
accessor: 'active',
|
||||
Header: t('Is active?'),
|
||||
id: 'active',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { active },
|
||||
@@ -269,30 +273,60 @@ function UsersList({ user }: UsersListProps) {
|
||||
{
|
||||
accessor: 'roles',
|
||||
Header: t('Roles'),
|
||||
id: 'roles',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { roles },
|
||||
},
|
||||
}: any) => (
|
||||
<span>{roles.map((role: Role) => role.name).join(', ')}</span>
|
||||
<Tooltip
|
||||
title={
|
||||
roles?.map((role: Role) => role.name).join(', ') || t('No roles')
|
||||
}
|
||||
>
|
||||
<span>{roles?.map((role: Role) => role.name).join(', ')}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
disableSortBy: true,
|
||||
},
|
||||
{
|
||||
accessor: 'groups',
|
||||
Header: t('Groups'),
|
||||
id: 'groups',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { groups },
|
||||
},
|
||||
}: any) => (
|
||||
<Tooltip
|
||||
title={
|
||||
groups?.map((group: Group) => group.name).join(', ') ||
|
||||
t('No groups')
|
||||
}
|
||||
>
|
||||
<span>{groups?.map((group: Group) => group.name).join(', ')}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
disableSortBy: true,
|
||||
},
|
||||
{
|
||||
accessor: 'login_count',
|
||||
Header: t('Login count'),
|
||||
id: 'login_count',
|
||||
hidden: true,
|
||||
Cell: ({ row: { original } }: any) => original.login_count,
|
||||
},
|
||||
{
|
||||
accessor: 'fail_login_count',
|
||||
Header: t('Fail login count'),
|
||||
id: 'fail_login_count',
|
||||
hidden: true,
|
||||
Cell: ({ row: { original } }: any) => original.fail_login_count,
|
||||
},
|
||||
{
|
||||
accessor: 'created_on',
|
||||
Header: t('Created on'),
|
||||
id: 'created_on',
|
||||
hidden: true,
|
||||
Cell: ({
|
||||
row: {
|
||||
@@ -302,6 +336,7 @@ function UsersList({ user }: UsersListProps) {
|
||||
},
|
||||
{
|
||||
accessor: 'changed_on',
|
||||
id: 'changed_on',
|
||||
Header: t('Changed on'),
|
||||
hidden: true,
|
||||
Cell: ({
|
||||
@@ -313,6 +348,7 @@ function UsersList({ user }: UsersListProps) {
|
||||
{
|
||||
accessor: 'last_login',
|
||||
Header: t('Last login'),
|
||||
id: 'last_login',
|
||||
hidden: true,
|
||||
Cell: ({
|
||||
row: {
|
||||
@@ -380,7 +416,7 @@ function UsersList({ user }: UsersListProps) {
|
||||
onClick: () => {
|
||||
openModal(ModalType.ADD);
|
||||
},
|
||||
loading: isLoading,
|
||||
loading: loadingState.roles || loadingState.groups,
|
||||
'data-test': 'add-user-button',
|
||||
},
|
||||
{
|
||||
@@ -432,7 +468,6 @@ function UsersList({ user }: UsersListProps) {
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
})),
|
||||
loading: isLoading,
|
||||
},
|
||||
{
|
||||
Header: t('Roles'),
|
||||
@@ -445,7 +480,20 @@ function UsersList({ user }: UsersListProps) {
|
||||
label: role.name,
|
||||
value: role.id,
|
||||
})),
|
||||
loading: isLoading,
|
||||
loading: loadingState.roles,
|
||||
},
|
||||
{
|
||||
Header: t('Groups'),
|
||||
key: 'groups',
|
||||
id: 'groups',
|
||||
input: 'select',
|
||||
operator: FilterOperator.RelationManyMany,
|
||||
unfilteredLabel: t('All'),
|
||||
selects: groups?.map(group => ({
|
||||
label: group.name,
|
||||
value: group.id,
|
||||
})),
|
||||
loading: loadingState.groups,
|
||||
},
|
||||
{
|
||||
Header: t('Created on'),
|
||||
@@ -488,7 +536,14 @@ function UsersList({ user }: UsersListProps) {
|
||||
operator: FilterOperator.Between,
|
||||
},
|
||||
],
|
||||
[isLoading, roles, loginCountStats, failLoginCountStats],
|
||||
[
|
||||
loadingState.roles,
|
||||
roles,
|
||||
groups,
|
||||
loadingState.groups,
|
||||
loginCountStats,
|
||||
failLoginCountStats,
|
||||
],
|
||||
);
|
||||
|
||||
const emptyState = {
|
||||
@@ -525,6 +580,7 @@ function UsersList({ user }: UsersListProps) {
|
||||
closeModal(ModalType.ADD);
|
||||
}}
|
||||
roles={roles}
|
||||
groups={groups}
|
||||
/>
|
||||
{modalState.edit && currentUser && (
|
||||
<UserListEditModal
|
||||
@@ -536,6 +592,7 @@ function UsersList({ user }: UsersListProps) {
|
||||
closeModal(ModalType.EDIT);
|
||||
}}
|
||||
roles={roles}
|
||||
groups={groups}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
113
superset-frontend/src/utils/fetchOptions.ts
Normal file
113
superset-frontend/src/utils/fetchOptions.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { SupersetClient, t } from '@superset-ui/core';
|
||||
import rison from 'rison';
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
interface FetchPaginatedOptions {
|
||||
endpoint: string;
|
||||
pageSize?: number;
|
||||
setData: (data: any[]) => void;
|
||||
setLoadingState: Dispatch<SetStateAction<any>>;
|
||||
filters?: SupersetFilter[];
|
||||
loadingKey: string;
|
||||
addDangerToast: (message: string) => void;
|
||||
errorMessage?: string;
|
||||
mapResult?: (item: any) => any;
|
||||
}
|
||||
|
||||
interface QueryObj {
|
||||
page_size: number;
|
||||
page: number;
|
||||
filters?: SupersetFilter[];
|
||||
}
|
||||
|
||||
interface SupersetFilter {
|
||||
col: string;
|
||||
opr: string;
|
||||
value: string | number | (string | number)[];
|
||||
}
|
||||
|
||||
export const fetchPaginatedData = async ({
|
||||
endpoint,
|
||||
pageSize = 100,
|
||||
setData,
|
||||
filters,
|
||||
setLoadingState,
|
||||
loadingKey,
|
||||
addDangerToast,
|
||||
errorMessage = 'Error while fetching data',
|
||||
mapResult = (item: any) => item,
|
||||
}: FetchPaginatedOptions) => {
|
||||
try {
|
||||
const fetchPage = async (pageIndex: number) => {
|
||||
const queryObj: QueryObj = {
|
||||
page_size: pageSize,
|
||||
page: pageIndex,
|
||||
};
|
||||
if (filters) {
|
||||
queryObj.filters = filters;
|
||||
}
|
||||
const encodedQuery = rison.encode(queryObj);
|
||||
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: `${endpoint}?q=${encodedQuery}`,
|
||||
});
|
||||
|
||||
return {
|
||||
count: response.json.count,
|
||||
results: response.json.result.map(mapResult),
|
||||
};
|
||||
};
|
||||
|
||||
const initialResponse = await fetchPage(0);
|
||||
const totalItems = initialResponse.count;
|
||||
const firstPageResults = initialResponse.results;
|
||||
|
||||
if (pageSize >= totalItems) {
|
||||
setData(firstPageResults);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(totalItems / pageSize);
|
||||
|
||||
const requests = Array.from({ length: totalPages - 1 }, (_, i) =>
|
||||
fetchPage(i + 1),
|
||||
);
|
||||
const remainingResults = await Promise.all(requests);
|
||||
|
||||
setData([
|
||||
...firstPageResults,
|
||||
...remainingResults.flatMap(res => res.results),
|
||||
]);
|
||||
} catch (err) {
|
||||
addDangerToast(t(errorMessage));
|
||||
} finally {
|
||||
setLoadingState((prev: boolean | Record<string, boolean>) => {
|
||||
if (typeof prev === 'boolean') {
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[loadingKey]: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -141,6 +141,9 @@ const ActionLogList: LazyExoticComponent<any> = lazy(
|
||||
() => import(/* webpackChunkName: "ActionLogList" */ 'src/pages/ActionLog'),
|
||||
);
|
||||
|
||||
const GroupsList: LazyExoticComponent<any> = lazy(
|
||||
() => import(/* webpackChunkName: "GroupsList" */ 'src/pages/GroupsList'),
|
||||
);
|
||||
type Routes = {
|
||||
path: string;
|
||||
Component: ComponentType;
|
||||
@@ -273,6 +276,10 @@ if (isAdmin) {
|
||||
path: '/users/',
|
||||
Component: UsersList,
|
||||
},
|
||||
{
|
||||
path: '/list_groups/',
|
||||
Component: GroupsList,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
from superset.views.dynamic_plugins import DynamicPluginsView
|
||||
from superset.views.error_handling import set_app_error_handlers
|
||||
from superset.views.explore import ExplorePermalinkView, ExploreView
|
||||
from superset.views.groups import GroupsListView
|
||||
from superset.views.log.api import LogRestApi
|
||||
from superset.views.logs import ActionLogView
|
||||
from superset.views.roles import RolesListView
|
||||
@@ -298,6 +299,14 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
category_label=__("Security"),
|
||||
)
|
||||
|
||||
appbuilder.add_view(
|
||||
GroupsListView,
|
||||
"List Groups",
|
||||
label=__("List Groups"),
|
||||
category="Security",
|
||||
category_label=__("Security"),
|
||||
)
|
||||
|
||||
appbuilder.add_view(
|
||||
DynamicPluginsView,
|
||||
"Plugins",
|
||||
|
||||
@@ -308,6 +308,9 @@ class RoleRestAPI(BaseSupersetApi):
|
||||
Role.permissions.any(id=filter_dict["permission_ids"])
|
||||
)
|
||||
|
||||
if "group_ids" in filter_dict:
|
||||
query = query.filter(Role.groups.any(id=filter_dict["group_ids"]))
|
||||
|
||||
if "name" in filter_dict:
|
||||
query = query.filter(Role.name.ilike(f"%{filter_dict['name']}%"))
|
||||
|
||||
@@ -323,6 +326,7 @@ class RoleRestAPI(BaseSupersetApi):
|
||||
"name": role.name,
|
||||
"user_ids": [user.id for user in role.user],
|
||||
"permission_ids": [perm.id for perm in role.permissions],
|
||||
"group_ids": [group.id for group in role.groups],
|
||||
}
|
||||
for role in roles
|
||||
],
|
||||
|
||||
@@ -145,6 +145,7 @@ class SupersetUserApi(UserApi):
|
||||
search_columns = [
|
||||
"id",
|
||||
"roles",
|
||||
"groups",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"username",
|
||||
@@ -281,6 +282,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
"User's Statistics",
|
||||
# Guarding all AB_ADD_SECURITY_API = True REST APIs
|
||||
"RoleRestAPI",
|
||||
"Group",
|
||||
"Role",
|
||||
"Permission",
|
||||
"PermissionViewMenu",
|
||||
@@ -2790,7 +2792,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
for view in list(self.appbuilder.baseviews):
|
||||
if isinstance(view, self.rolemodelview.__class__) and getattr(
|
||||
view, "route_base", None
|
||||
) in ["/roles", "/users"]:
|
||||
) in ["/roles", "/users", "/groups"]:
|
||||
self.appbuilder.baseviews.remove(view)
|
||||
|
||||
security_menu = next(
|
||||
@@ -2798,5 +2800,5 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
)
|
||||
if security_menu:
|
||||
for item in list(security_menu.childs):
|
||||
if item.name in ["List Roles", "List Users"]:
|
||||
if item.name in ["List Roles", "List Users", "List Groups"]:
|
||||
security_menu.childs.remove(item)
|
||||
|
||||
34
superset/views/groups.py
Normal file
34
superset/views/groups.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from flask_appbuilder import permission_name
|
||||
from flask_appbuilder.api import expose
|
||||
from flask_appbuilder.security.decorators import has_access
|
||||
|
||||
from superset.superset_typing import FlaskResponse
|
||||
|
||||
from .base import BaseSupersetView
|
||||
|
||||
|
||||
class GroupsListView(BaseSupersetView):
|
||||
route_base = "/"
|
||||
class_permission_name = "security"
|
||||
|
||||
@expose("/list_groups/")
|
||||
@has_access
|
||||
@permission_name("read")
|
||||
def list(self) -> FlaskResponse:
|
||||
return super().render_app_template()
|
||||
Reference in New Issue
Block a user