blob: f9787a214e3b81c8a87d753c6672596d1e739c1a [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { t } 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 {
Tooltip,
Icons,
DeleteModal,
ConfirmStatusChange,
} from '@superset-ui/core/components';
import {
ListView,
ListViewProps,
ListViewFilterOperator,
ListViewFilters,
} from 'src/components';
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
import {
UserListAddModal,
UserListEditModal,
} 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 type { UsersListProps, Group, Role, UserObject } from './types';
const PAGE_SIZE = 25;
enum ModalType {
ADD = 'add',
EDIT = 'edit',
}
const isActiveOptions = [
{
label: 'Yes',
value: true,
},
{
label: 'No',
value: false,
},
];
function UsersList({ user }: UsersListProps) {
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,
});
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 [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 };
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(() => {
fetchPaginatedData({
endpoint: '/api/v1/security/roles/',
setData: setRoles,
setLoadingState,
loadingKey: 'roles',
addDangerToast,
errorMessage: t('Error while fetching roles'),
});
}, [addDangerToast]);
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);
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',
id: 'first_name',
Header: t('First name'),
size: 'lg',
Cell: ({
row: {
original: { first_name },
},
}: any) => <span>{first_name}</span>,
},
{
accessor: 'last_name',
id: 'last_name',
Header: t('Last name'),
size: 'lg',
Cell: ({
row: {
original: { last_name },
},
}: any) => <span>{last_name}</span>,
},
{
accessor: 'username',
id: 'username',
Header: t('Username'),
size: 'xxl',
Cell: ({
row: {
original: { username },
},
}: any) => <span>{username}</span>,
},
{
accessor: 'email',
id: 'email',
Header: t('Email'),
size: 'xl',
Cell: ({
row: {
original: { email },
},
}: any) => <span>{email}</span>,
},
{
accessor: 'active',
id: 'active',
Header: t('Is active?'),
size: 'sm',
Cell: ({
row: {
original: { active },
},
}: any) => <span>{active ? 'Yes' : 'No'}</span>,
},
{
accessor: 'roles',
id: 'roles',
Header: t('Roles'),
size: 'lg',
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: 'groups',
Header: t('Groups'),
id: 'groups',
size: 'lg',
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',
id: 'login_count',
Header: t('Login count'),
hidden: true,
Cell: ({ row: { original } }: any) => original.login_count,
},
{
accessor: 'fail_login_count',
id: 'fail_login_count',
Header: t('Fail login count'),
hidden: true,
Cell: ({ row: { original } }: any) => original.fail_login_count,
},
{
accessor: 'created_on',
id: 'created_on',
Header: t('Created on'),
hidden: true,
Cell: ({
row: {
original: { created_on },
},
}: any) => created_on,
},
{
accessor: 'changed_on',
id: 'changed_on',
Header: t('Changed on'),
hidden: true,
Cell: ({
row: {
original: { changed_on },
},
}: any) => changed_on,
},
{
accessor: 'last_login',
id: '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: t('Bulk select'),
onClick: toggleBulkSelect,
buttonStyle: 'secondary',
},
{
icon: <Icons.PlusOutlined iconSize="m" />,
name: t('User'),
buttonStyle: 'primary',
onClick: () => {
openModal(ModalType.ADD);
},
loading: loadingState.roles || loadingState.groups,
'data-test': 'add-user-button',
},
);
}
const filters: ListViewFilters = useMemo(
() => [
{
Header: t('First name'),
key: 'first_name',
id: 'first_name',
input: 'search',
operator: ListViewFilterOperator.Contains,
},
{
Header: t('Last name'),
key: 'last_name',
id: 'last_name',
input: 'search',
operator: ListViewFilterOperator.Contains,
},
{
Header: t('Username'),
key: 'username',
id: 'username',
input: 'search',
operator: ListViewFilterOperator.Contains,
},
{
Header: t('Email'),
key: 'email',
id: 'email',
input: 'search',
operator: ListViewFilterOperator.Contains,
},
{
Header: t('Is active?'),
key: 'active',
id: 'active',
input: 'select',
operator: ListViewFilterOperator.Equals,
unfilteredLabel: t('All'),
selects: isActiveOptions?.map(option => ({
label: option.label,
value: option.value,
})),
},
{
Header: t('Roles'),
key: 'roles',
id: 'roles',
input: 'select',
operator: ListViewFilterOperator.RelationManyMany,
unfilteredLabel: t('All'),
selects: roles?.map(role => ({
label: role.name,
value: role.id,
})),
loading: loadingState.roles,
},
{
Header: t('Groups'),
key: 'groups',
id: 'groups',
input: 'select',
operator: ListViewFilterOperator.RelationManyMany,
unfilteredLabel: t('All'),
selects: groups?.map(group => ({
label: group.name,
value: group.id,
})),
loading: loadingState.groups,
},
{
Header: t('Created on'),
key: 'created_on',
id: 'created_on',
input: 'datetime_range',
operator: ListViewFilterOperator.Between,
dateFilterValueType: 'iso',
},
{
Header: t('Changed on'),
key: 'changed_on',
id: 'changed_on',
input: 'datetime_range',
operator: ListViewFilterOperator.Between,
dateFilterValueType: 'iso',
},
{
Header: t('Last login'),
key: 'last_login',
id: 'last_login',
input: 'datetime_range',
operator: ListViewFilterOperator.Between,
dateFilterValueType: 'iso',
},
{
Header: t('Login count'),
key: 'login_count',
id: 'login_count',
input: 'numerical_range',
operator: ListViewFilterOperator.Between,
min: loginCountStats.min,
max: loginCountStats.max,
},
{
Header: t('Fail login count'),
key: 'fail_login_count',
id: 'fail_login_count',
input: 'numerical_range',
operator: ListViewFilterOperator.Between,
},
],
[
loadingState.roles,
roles,
groups,
loadingState.groups,
loginCountStats,
failLoginCountStats,
],
);
const emptyState = {
title: t('No users yet'),
image: 'filter-results.svg',
...(isAdmin && {
buttonAction: () => {
openModal(ModalType.ADD);
},
buttonText: (
<>
<Icons.PlusOutlined iconSize="m" />
{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}
groups={groups}
/>
{modalState.edit && currentUser && (
<UserListEditModal
user={currentUser}
show={modalState.edit}
onHide={() => closeModal(ModalType.EDIT)}
onSave={() => {
refreshData();
closeModal(ModalType.EDIT);
}}
roles={roles}
groups={groups}
/>
)}
{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;