mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-10-30 10:05:41 +00:00
* Fix #3683: Allow Admins to delete a user from Users page
This commit is contained in:
parent
909cfe5736
commit
40fa4dcdcb
@ -106,3 +106,7 @@ export const getUserCounts = () => {
|
||||
`/search/query?q=*&from=0&size=0&index=${SearchIndex.USER}`
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteUser = (id: string) => {
|
||||
return APIClient.delete(`/users/${id}`);
|
||||
};
|
||||
|
||||
@ -26,16 +26,43 @@ const mockItem = {
|
||||
teamCount: 'Cloud_Infra',
|
||||
};
|
||||
|
||||
const mockRemove = jest.fn();
|
||||
const mockSelect = jest.fn();
|
||||
const mockDelete = jest.fn();
|
||||
|
||||
jest.mock('../../auth-provider/AuthProvider', () => {
|
||||
return {
|
||||
useAuthContext: jest.fn(() => ({
|
||||
isAuthDisabled: false,
|
||||
isAuthenticated: true,
|
||||
isProtectedRoute: jest.fn().mockReturnValue(true),
|
||||
isTourRoute: jest.fn().mockReturnValue(false),
|
||||
onLogoutHandler: jest.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../components/common/avatar/Avatar', () => {
|
||||
return jest.fn().mockReturnValue(<p data-testid="avatar">Avatar</p>);
|
||||
});
|
||||
|
||||
jest.mock('../../utils/SvgUtils', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: jest.fn().mockReturnValue(<p data-testid="svg-icon">SVGIcons</p>),
|
||||
Icons: {
|
||||
DELETE: 'delete',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test UserDataCard component', () => {
|
||||
it('Component should render', async () => {
|
||||
const { container } = render(
|
||||
<UserDataCard item={mockItem} onClick={mockRemove} />,
|
||||
<UserDataCard
|
||||
item={mockItem}
|
||||
onClick={mockSelect}
|
||||
onDelete={mockDelete}
|
||||
/>,
|
||||
{
|
||||
wrapper: MemoryRouter,
|
||||
}
|
||||
@ -50,7 +77,11 @@ describe('Test UserDataCard component', () => {
|
||||
|
||||
it('Data should render', async () => {
|
||||
const { container } = render(
|
||||
<UserDataCard item={mockItem} onClick={mockRemove} />,
|
||||
<UserDataCard
|
||||
item={mockItem}
|
||||
onClick={mockSelect}
|
||||
onDelete={mockDelete}
|
||||
/>,
|
||||
{
|
||||
wrapper: MemoryRouter,
|
||||
}
|
||||
|
||||
@ -12,8 +12,11 @@
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { isNil } from 'lodash';
|
||||
import React from 'react';
|
||||
import SVGIcons, { Icons } from '../../utils/SvgUtils';
|
||||
import Avatar from '../common/avatar/Avatar';
|
||||
import NonAdminAction from '../common/non-admin-action/NonAdminAction';
|
||||
|
||||
type Item = {
|
||||
description: string;
|
||||
@ -28,13 +31,15 @@ type Item = {
|
||||
type Props = {
|
||||
item: Item;
|
||||
onClick: (value: string) => void;
|
||||
onDelete?: (id: string, name: string) => void;
|
||||
};
|
||||
|
||||
const UserDataCard = ({ item, onClick }: Props) => {
|
||||
const UserDataCard = ({ item, onClick, onDelete }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className="tw-card tw-flex tw-gap-1 tw-py-2 tw-px-3 tw-group"
|
||||
className="tw-card tw-flex tw-justify-between tw-py-2 tw-px-3 tw-group"
|
||||
data-testid="user-card-container">
|
||||
<div className="tw-flex tw-gap-1">
|
||||
{item.profilePhoto ? (
|
||||
<div className="tw-h-9 tw-w-9">
|
||||
<img
|
||||
@ -70,6 +75,31 @@ const UserDataCard = ({ item, onClick }: Props) => {
|
||||
<p>Teams: {item.teamCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isNil(onDelete) && (
|
||||
<div className="tw-flex-none">
|
||||
<NonAdminAction
|
||||
position="bottom"
|
||||
title="You do not have permission to delete user.">
|
||||
<span
|
||||
className="tw-h-8 tw-rounded tw-mb-3"
|
||||
data-testid="remove"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete(item.id as string, item.description);
|
||||
}}>
|
||||
<SVGIcons
|
||||
alt="delete"
|
||||
className="tw-cursor-pointer tw-opacity-0 group-hover:tw-opacity-100"
|
||||
icon={Icons.DELETE}
|
||||
title="Delete"
|
||||
width="12px"
|
||||
/>
|
||||
</span>
|
||||
</NonAdminAction>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ import { Button } from '../buttons/Button/Button';
|
||||
import ErrorPlaceHolder from '../common/error-with-placeholder/ErrorPlaceHolder';
|
||||
import NonAdminAction from '../common/non-admin-action/NonAdminAction';
|
||||
import Searchbar from '../common/searchbar/Searchbar';
|
||||
import ConfirmationModal from '../Modals/ConfirmationModal/ConfirmationModal';
|
||||
import UserDetailsModal from '../Modals/UserDetailsModal/UserDetailsModal';
|
||||
import UserDataCard from '../UserDataCard/UserDataCard';
|
||||
|
||||
@ -34,14 +35,21 @@ interface Props {
|
||||
teams: Array<Team>;
|
||||
roles: Array<Role>;
|
||||
allUsers: Array<User>;
|
||||
deleteUser: (id: string) => void;
|
||||
updateUser: (id: string, data: Operation[], updatedUser: User) => void;
|
||||
handleAddUserClick: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
interface DeleteUserInfo {
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const UserList: FunctionComponent<Props> = ({
|
||||
allUsers = [],
|
||||
isLoading,
|
||||
deleteUser,
|
||||
updateUser,
|
||||
handleAddUserClick,
|
||||
teams = [],
|
||||
@ -55,6 +63,7 @@ const UserList: FunctionComponent<Props> = ({
|
||||
const [currentTab, setCurrentTab] = useState<number>(1);
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [deletingUser, setDeletingUser] = useState<DeleteUserInfo>();
|
||||
|
||||
const handleSearchAction = (searchValue: string) => {
|
||||
setSearchText(searchValue);
|
||||
@ -160,6 +169,18 @@ const UserList: FunctionComponent<Props> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = (id: string, name: string) => {
|
||||
setDeletingUser({
|
||||
name,
|
||||
id,
|
||||
});
|
||||
};
|
||||
|
||||
const onConfirmDeleteUser = (id: string) => {
|
||||
deleteUser(id);
|
||||
setDeletingUser(undefined);
|
||||
};
|
||||
|
||||
const handleTabChange = (tab: number) => {
|
||||
setSearchText('');
|
||||
setCurrentTab(tab);
|
||||
@ -343,7 +364,11 @@ const UserList: FunctionComponent<Props> = ({
|
||||
className="tw-cursor-pointer"
|
||||
key={index}
|
||||
onClick={() => selectUser(User.id)}>
|
||||
<UserDataCard item={User} onClick={selectUser} />
|
||||
<UserDataCard
|
||||
item={User}
|
||||
onClick={selectUser}
|
||||
onDelete={handleDeleteUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -376,6 +401,18 @@ const UserList: FunctionComponent<Props> = ({
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
{!isUndefined(deletingUser) && (
|
||||
<ConfirmationModal
|
||||
bodyText={`Are you sure you want to delete ${deletingUser.name}?`}
|
||||
cancelText="Cancel"
|
||||
confirmText="Confirm"
|
||||
header="Delete user"
|
||||
onCancel={() => setDeletingUser(undefined)}
|
||||
onConfirm={() => {
|
||||
onConfirmDeleteUser(deletingUser.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -27,11 +27,12 @@ const jsonData = {
|
||||
|
||||
'delete-glossary-error': 'Error while deleting glossary!',
|
||||
'delete-glossary-term-error': 'Error while deleting glossary term!',
|
||||
'delete-team-error': 'Error while deleting team!',
|
||||
'delete-lineage-error': 'Error while deleting edge!',
|
||||
'delete-test-error': 'Error while deleting test!',
|
||||
'delete-message-error': 'Error while deleting message!',
|
||||
'delete-ingestion-error': 'Error while deleting ingestion workflow',
|
||||
'delete-lineage-error': 'Error while deleting edge!',
|
||||
'delete-message-error': 'Error while deleting message!',
|
||||
'delete-team-error': 'Error while deleting team!',
|
||||
'delete-test-error': 'Error while deleting test!',
|
||||
'delete-user-error': 'Error while deleting user!',
|
||||
|
||||
'elastic-search-error': 'Error while fetch data from Elasticsearch!',
|
||||
|
||||
@ -46,6 +47,7 @@ const jsonData = {
|
||||
'fetch-glossary-error': 'Error while fetching glossary!',
|
||||
'fetch-glossary-list-error': 'Error while fetching glossaries!',
|
||||
'fetch-glossary-term-error': 'Error while fetching glossary term!',
|
||||
'fetch-ingestion-error': 'Error while fetching ingestion workflow!',
|
||||
'fetch-lineage-error': 'Error while fetching lineage data!',
|
||||
'fetch-lineage-node-error': 'Error while fetching lineage node!',
|
||||
'fetch-pipeline-details-error': 'Error while fetching pipeline details!',
|
||||
@ -57,23 +59,24 @@ const jsonData = {
|
||||
'fetch-thread-error': 'Error while fetching threads!',
|
||||
'fetch-updated-conversation-error':
|
||||
'Error while fetching updated conversation!',
|
||||
'fetch-ingestion-error': 'Error while fetching ingestion workflow!',
|
||||
'fetch-service-error': 'Error while fetching service details!',
|
||||
'fetch-teams-error': 'Error while fetching teams!',
|
||||
|
||||
'unexpected-server-response': 'Unexpected response from server!',
|
||||
|
||||
'update-chart-error': 'Error while updating charts!',
|
||||
'update-owner-error': 'Error while updating owner',
|
||||
'update-glossary-term-error': 'Error while updating glossary term!',
|
||||
'update-description-error': 'Error while updating description!',
|
||||
'update-entity-error': 'Error while updating entity!',
|
||||
'update-team-error': 'Error while updating team',
|
||||
'update-tags-error': 'Error while updating tags!',
|
||||
'update-task-error': 'Error while updating tasks!',
|
||||
'update-entity-follow-error': 'Error while following entity!',
|
||||
'update-entity-unfollow-error': 'Error while unfollowing entity!',
|
||||
'update-glossary-term-error': 'Error while updating glossary term!',
|
||||
'update-ingestion-error': 'Error while updating ingestion workflow',
|
||||
'update-owner-error': 'Error while updating owner',
|
||||
'update-service-config-error': 'Error while updating ingestion workflow',
|
||||
'update-tags-error': 'Error while updating tags!',
|
||||
'update-task-error': 'Error while updating tasks!',
|
||||
'update-team-error': 'Error while updating team!',
|
||||
'update-user-error': 'Error while updating user!',
|
||||
},
|
||||
'api-success-messages': {
|
||||
'create-conversation': 'Conversation created successfully!',
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import { findByTestId, findByText, render } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
findByTestId,
|
||||
findByText,
|
||||
fireEvent,
|
||||
render,
|
||||
} from '@testing-library/react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { deleteUser } from '../../axiosAPIs/userAPI';
|
||||
import UserListPage from './UserListPage';
|
||||
|
||||
jest.mock('../../components/containers/PageContainerV1', () => {
|
||||
@ -12,13 +19,23 @@ jest.mock('../../components/containers/PageContainerV1', () => {
|
||||
});
|
||||
|
||||
jest.mock('../../components/UserList/UserList', () => {
|
||||
return jest.fn().mockImplementation(() => <div>UserListComponent</div>);
|
||||
return jest
|
||||
.fn()
|
||||
.mockImplementation(({ deleteUser }) => (
|
||||
<div onClick={deleteUser}>UserListComponent</div>
|
||||
));
|
||||
});
|
||||
|
||||
jest.mock('../../axiosAPIs/teamsAPI', () => ({
|
||||
getTeams: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('../../axiosAPIs/userAPI', () => ({
|
||||
deleteUser: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
const mockDeleteUser = jest.fn(() => Promise.resolve({}));
|
||||
|
||||
describe('Test UserListPage component', () => {
|
||||
it('UserListPage component should render properly', async () => {
|
||||
const { container } = render(<UserListPage />, {
|
||||
@ -31,4 +48,18 @@ describe('Test UserListPage component', () => {
|
||||
expect(PageContainerV1).toBeInTheDocument();
|
||||
expect(UserListComponent).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should delete users', async () => {
|
||||
(deleteUser as jest.Mock).mockImplementationOnce(mockDeleteUser);
|
||||
|
||||
const { container } = render(<UserListPage />, {
|
||||
wrapper: MemoryRouter,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(await findByText(container, 'UserListComponent'));
|
||||
});
|
||||
|
||||
expect(mockDeleteUser).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -13,12 +13,13 @@
|
||||
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
import { isNil } from 'lodash';
|
||||
import { observer } from 'mobx-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import AppState from '../../AppState';
|
||||
import { getTeams } from '../../axiosAPIs/teamsAPI';
|
||||
import { updateUserDetail } from '../../axiosAPIs/userAPI';
|
||||
import { deleteUser, updateUserDetail } from '../../axiosAPIs/userAPI';
|
||||
import PageContainerV1 from '../../components/containers/PageContainerV1';
|
||||
import UserList from '../../components/UserList/UserList';
|
||||
import { ROUTES } from '../../constants/constants';
|
||||
@ -26,6 +27,8 @@ import { Role } from '../../generated/entity/teams/role';
|
||||
import { Team } from '../../generated/entity/teams/team';
|
||||
import { User } from '../../generated/entity/teams/user';
|
||||
import useToastContext from '../../hooks/useToastContext';
|
||||
import jsonData from '../../jsons/en';
|
||||
import { getErrorText } from '../../utils/StringsUtils';
|
||||
|
||||
const UserListPage = () => {
|
||||
const showToast = useToastContext();
|
||||
@ -33,20 +36,30 @@ const UserListPage = () => {
|
||||
|
||||
const [teams, setTeams] = useState<Array<Team>>([]);
|
||||
const [roles, setRoles] = useState<Array<Role>>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [allUsers, setAllUsers] = useState<Array<User>>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [allUsers, setAllUsers] = useState<Array<User>>();
|
||||
|
||||
const handleShowErrorToast = (message: string) => {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
body: message,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchTeams = () => {
|
||||
setIsLoading(true);
|
||||
getTeams(['users'])
|
||||
.then((res: AxiosResponse) => {
|
||||
if (res.data) {
|
||||
setTeams(res.data.data);
|
||||
} else {
|
||||
throw jsonData['api-error-messages']['unexpected-server-response'];
|
||||
}
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
body: err.message || 'No teams available!',
|
||||
});
|
||||
handleShowErrorToast(
|
||||
getErrorText(err, jsonData['api-error-messages']['fetch-teams-error'])
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
@ -63,9 +76,10 @@ const UserListPage = () => {
|
||||
const updateUser = (id: string, data: Operation[], updatedUser: User) => {
|
||||
setIsLoading(true);
|
||||
updateUserDetail(id, data)
|
||||
.then(() => {
|
||||
.then((res) => {
|
||||
if (res.data) {
|
||||
setAllUsers(
|
||||
allUsers.map((user) => {
|
||||
(allUsers || []).map((user) => {
|
||||
if (user.id === id) {
|
||||
return updatedUser;
|
||||
}
|
||||
@ -73,6 +87,31 @@ const UserListPage = () => {
|
||||
return user;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
throw jsonData['api-error-messages']['unexpected-server-response'];
|
||||
}
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
handleShowErrorToast(
|
||||
getErrorText(err, jsonData['api-error-messages']['update-user-error'])
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteUser = (id: string) => {
|
||||
setIsLoading(true);
|
||||
deleteUser(id)
|
||||
.then(() => {
|
||||
AppState.updateUsers((allUsers || []).filter((item) => item.id !== id));
|
||||
fetchTeams();
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
handleShowErrorToast(
|
||||
getErrorText(err, jsonData['api-error-messages']['delete-user-error'])
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
@ -80,7 +119,11 @@ const UserListPage = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (AppState.users.length) {
|
||||
setAllUsers(AppState.users);
|
||||
} else {
|
||||
setAllUsers(undefined);
|
||||
}
|
||||
}, [AppState.users]);
|
||||
useEffect(() => {
|
||||
setRoles(AppState.userRoles);
|
||||
@ -93,9 +136,10 @@ const UserListPage = () => {
|
||||
return (
|
||||
<PageContainerV1>
|
||||
<UserList
|
||||
allUsers={allUsers}
|
||||
allUsers={allUsers || []}
|
||||
deleteUser={handleDeleteUser}
|
||||
handleAddUserClick={handleAddUserClick}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || isNil(allUsers)}
|
||||
roles={roles}
|
||||
teams={teams}
|
||||
updateUser={updateUser}
|
||||
|
||||
@ -797,7 +797,7 @@ const TeamsPage = () => {
|
||||
)}
|
||||
{deletingUser.state && (
|
||||
<ConfirmationModal
|
||||
bodyText={`Are you sure want to remove ${
|
||||
bodyText={`Are you sure you want to remove ${
|
||||
deletingUser.user?.displayName ?? deletingUser.user?.name
|
||||
}?`}
|
||||
cancelText="Cancel"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user