You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
overseerr/src/components/UserList/index.tsx

745 lines
26 KiB

import { TrashIcon } from '@heroicons/react/outline';
import {
ChevronLeftIcon,
ChevronRightIcon,
InboxInIcon,
PencilIcon,
SortDescendingIcon,
UserAddIcon,
} from '@heroicons/react/solid';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
import type { UserResultsResponse } from '../../../server/interfaces/api/userInterfaces';
import { hasPermission } from '../../../server/lib/permissions';
import useSettings from '../../hooks/useSettings';
import { useUpdateQueryParams } from '../../hooks/useUpdateQueryParams';
import type { User } from '../../hooks/useUser';
import { Permission, UserType, useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Alert from '../Common/Alert';
import Badge from '../Common/Badge';
import Button from '../Common/Button';
import Header from '../Common/Header';
import LoadingSpinner from '../Common/LoadingSpinner';
import Modal from '../Common/Modal';
import PageTitle from '../Common/PageTitle';
import SensitiveInput from '../Common/SensitiveInput';
import Table from '../Common/Table';
import Transition from '../Transition';
import BulkEditModal from './BulkEditModal';
import PlexImportModal from './PlexImportModal';
const messages = defineMessages({
users: 'Users',
userlist: 'User List',
importfromplex: 'Import Plex Users',
user: 'User',
totalrequests: 'Requests',
accounttype: 'Type',
role: 'Role',
created: 'Joined',
bulkedit: 'Bulk Edit',
owner: 'Owner',
admin: 'Admin',
plexuser: 'Plex User',
deleteuser: 'Delete User',
userdeleted: 'User deleted successfully!',
userdeleteerror: 'Something went wrong while deleting the user.',
deleteconfirm:
'Are you sure you want to delete this user? All of their request data will be permanently removed.',
localuser: 'Local User',
createlocaluser: 'Create Local User',
creating: 'Creating…',
create: 'Create',
validationpasswordminchars:
'Password is too short; should be a minimum of 8 characters',
usercreatedfailed: 'Something went wrong while creating the user.',
usercreatedfailedexisting:
'The provided email address is already in use by another user.',
usercreatedsuccess: 'User created successfully!',
displayName: 'Display Name',
email: 'Email Address',
password: 'Password',
passwordinfodescription:
'Configure an application URL and enable email notifications to allow automatic password generation.',
autogeneratepassword: 'Automatically Generate Password',
autogeneratepasswordTip: 'Email a server-generated password to the user',
validationEmail: 'You must provide a valid email address',
sortCreated: 'Join Date',
sortDisplayName: 'Display Name',
sortRequests: 'Request Count',
localLoginDisabled:
'The <strong>Enable Local Sign-In</strong> setting is currently disabled.',
});
type Sort = 'created' | 'updated' | 'requests' | 'displayname';
const UserList: React.FC = () => {
const intl = useIntl();
const router = useRouter();
const settings = useSettings();
const { addToast } = useToasts();
const { user: currentUser, hasPermission: currentHasPermission } = useUser();
const [currentSort, setCurrentSort] = useState<Sort>('displayname');
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
const page = router.query.page ? Number(router.query.page) : 1;
const pageIndex = page - 1;
const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
const {
data,
error,
mutate: revalidate,
} = useSWR<UserResultsResponse>(
`/api/v1/user?take=${currentPageSize}&skip=${
pageIndex * currentPageSize
}&sort=${currentSort}`
);
const [isDeleting, setDeleting] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean;
user?: User;
}>({
isOpen: false,
});
const [createModal, setCreateModal] = useState<{
isOpen: boolean;
}>({
isOpen: false,
});
const [showBulkEditModal, setShowBulkEditModal] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
useEffect(() => {
const filterString = window.localStorage.getItem('ul-filter-settings');
if (filterString) {
const filterSettings = JSON.parse(filterString);
setCurrentSort(filterSettings.currentSort);
setCurrentPageSize(filterSettings.currentPageSize);
}
}, []);
useEffect(() => {
window.localStorage.setItem(
'ul-filter-settings',
JSON.stringify({
currentSort,
currentPageSize,
})
);
}, [currentSort, currentPageSize]);
const isUserPermsEditable = (userId: number) =>
userId !== 1 && userId !== currentUser?.id;
const isAllUsersSelected = () => {
return (
selectedUsers.length ===
data?.results.filter((user) => user.id !== currentUser?.id).length
);
};
const isUserSelected = (userId: number) => selectedUsers.includes(userId);
const toggleAllUsers = () => {
if (
data &&
selectedUsers.length >= 0 &&
selectedUsers.length < data?.results.length - 1
) {
setSelectedUsers(
data.results
.filter((user) => isUserPermsEditable(user.id))
.map((u) => u.id)
);
} else {
setSelectedUsers([]);
}
};
const toggleUser = (userId: number) => {
if (selectedUsers.includes(userId)) {
setSelectedUsers((users) => users.filter((u) => u !== userId));
} else {
setSelectedUsers((users) => [...users, userId]);
}
};
const deleteUser = async () => {
setDeleting(true);
try {
await axios.delete(`/api/v1/user/${deleteModal.user?.id}`);
addToast(intl.formatMessage(messages.userdeleted), {
autoDismiss: true,
appearance: 'success',
});
setDeleteModal({ isOpen: false });
} catch (e) {
addToast(intl.formatMessage(messages.userdeleteerror), {
autoDismiss: true,
appearance: 'error',
});
} finally {
setDeleting(false);
revalidate();
}
};
if (!data && !error) {
return <LoadingSpinner />;
}
const CreateUserSchema = Yup.object().shape({
email: Yup.string()
.required(intl.formatMessage(messages.validationEmail))
.email(intl.formatMessage(messages.validationEmail)),
password: Yup.lazy((value) =>
!value
? Yup.string()
: Yup.string().min(
8,
intl.formatMessage(messages.validationpasswordminchars)
)
),
});
if (!data) {
return <LoadingSpinner />;
}
const hasNextPage = data.pageInfo.pages > pageIndex + 1;
const hasPrevPage = pageIndex > 0;
const passwordGenerationEnabled =
settings.currentSettings.applicationUrl &&
settings.currentSettings.emailEnabled;
return (
<>
<PageTitle title={intl.formatMessage(messages.users)} />
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={deleteModal.isOpen}
>
<Modal
onOk={() => deleteUser()}
okText={
isDeleting
? intl.formatMessage(globalMessages.deleting)
: intl.formatMessage(globalMessages.delete)
}
okDisabled={isDeleting}
okButtonType="danger"
onCancel={() => setDeleteModal({ isOpen: false })}
title={intl.formatMessage(messages.deleteuser)}
iconSvg={<TrashIcon />}
>
{intl.formatMessage(messages.deleteconfirm)}
</Modal>
</Transition>
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={createModal.isOpen}
>
<Formik
initialValues={{
displayName: '',
email: '',
password: '',
genpassword: false,
}}
validationSchema={CreateUserSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/user', {
username: values.displayName,
email: values.email,
password: values.genpassword ? null : values.password,
});
addToast(intl.formatMessage(messages.usercreatedsuccess), {
appearance: 'success',
autoDismiss: true,
});
setCreateModal({ isOpen: false });
} catch (e) {
addToast(
intl.formatMessage(
e.response.data.errors?.includes('USER_EXISTS')
? messages.usercreatedfailedexisting
: messages.usercreatedfailed
),
{
appearance: 'error',
autoDismiss: true,
}
);
} finally {
revalidate();
}
}}
>
{({
errors,
touched,
isSubmitting,
values,
isValid,
setFieldValue,
handleSubmit,
}) => {
return (
<Modal
title={intl.formatMessage(messages.createlocaluser)}
iconSvg={<UserAddIcon />}
onOk={() => handleSubmit()}
okText={
isSubmitting
? intl.formatMessage(messages.creating)
: intl.formatMessage(messages.create)
}
okDisabled={isSubmitting || !isValid}
okButtonType="primary"
onCancel={() => setCreateModal({ isOpen: false })}
>
{!settings.currentSettings.localLogin && (
<Alert
title={intl.formatMessage(messages.localLoginDisabled, {
strong: function strong(msg) {
return (
<strong className="font-semibold text-white">
{msg}
</strong>
);
},
})}
type="warning"
/>
)}
{currentHasPermission(Permission.MANAGE_SETTINGS) &&
!passwordGenerationEnabled && (
<Alert
title={intl.formatMessage(
messages.passwordinfodescription
)}
type="info"
/>
)}
<Form className="section">
<div className="form-row">
<label htmlFor="displayName" className="text-label">
{intl.formatMessage(messages.displayName)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="displayName"
name="displayName"
type="text"
/>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="email"
name="email"
type="text"
inputMode="email"
/>
</div>
{errors.email && touched.email && (
<div className="error">{errors.email}</div>
)}
</div>
</div>
<div
className={`form-row ${
passwordGenerationEnabled ? '' : 'opacity-50'
}`}
>
<label htmlFor="genpassword" className="checkbox-label">
{intl.formatMessage(messages.autogeneratepassword)}
<span className="label-tip">
{intl.formatMessage(messages.autogeneratepasswordTip)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="genpassword"
name="genpassword"
disabled={!passwordGenerationEnabled}
onClick={() => setFieldValue('password', '')}
/>
</div>
</div>
<div
className={`form-row ${
values.genpassword ? 'opacity-50' : ''
}`}
>
<label htmlFor="password" className="text-label">
{intl.formatMessage(messages.password)}
{!values.genpassword && (
<span className="label-required">*</span>
)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
as="field"
id="password"
name="password"
type="password"
autoComplete="new-password"
disabled={values.genpassword}
/>
</div>
{errors.password && touched.password && (
<div className="error">{errors.password}</div>
)}
</div>
</div>
</Form>
</Modal>
);
}}
</Formik>
</Transition>
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showBulkEditModal}
>
<BulkEditModal
onCancel={() => setShowBulkEditModal(false)}
onComplete={() => {
setShowBulkEditModal(false);
revalidate();
}}
selectedUserIds={selectedUsers}
users={data.results}
/>
</Transition>
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showImportModal}
>
<PlexImportModal
onCancel={() => setShowImportModal(false)}
onComplete={() => {
setShowImportModal(false);
revalidate();
}}
/>
</Transition>
<div className="flex flex-col justify-between lg:flex-row lg:items-end">
<Header>{intl.formatMessage(messages.userlist)}</Header>
<div className="mt-2 flex flex-grow flex-col lg:flex-grow-0 lg:flex-row">
<div className="mb-2 flex flex-grow flex-col justify-between sm:flex-row lg:mb-0 lg:flex-grow-0">
<Button
className="mb-2 flex-grow sm:mb-0 sm:mr-2"
buttonType="primary"
onClick={() => setCreateModal({ isOpen: true })}
>
<UserAddIcon />
<span>{intl.formatMessage(messages.createlocaluser)}</span>
</Button>
<Button
className="flex-grow lg:mr-2"
buttonType="primary"
onClick={() => setShowImportModal(true)}
>
<InboxInIcon />
<span>{intl.formatMessage(messages.importfromplex)}</span>
</Button>
</div>
<div className="mb-2 flex flex-grow lg:mb-0 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<SortDescendingIcon className="h-6 w-6" />
</span>
<select
id="sort"
name="sort"
onChange={(e) => {
setCurrentSort(e.target.value as Sort);
router.push(router.pathname);
}}
value={currentSort}
className="rounded-r-only"
>
<option value="created">
{intl.formatMessage(messages.sortCreated)}
</option>
<option value="requests">
{intl.formatMessage(messages.sortRequests)}
</option>
<option value="displayname">
{intl.formatMessage(messages.sortDisplayName)}
</option>
</select>
</div>
</div>
</div>
<Table>
<thead>
<tr>
<Table.TH>
{(data.results ?? []).length > 1 && (
<input
type="checkbox"
id="selectAll"
name="selectAll"
checked={isAllUsersSelected()}
onChange={() => {
toggleAllUsers();
}}
/>
)}
</Table.TH>
<Table.TH>{intl.formatMessage(messages.user)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.totalrequests)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.accounttype)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.role)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.created)}</Table.TH>
<Table.TH className="text-right">
{(data.results ?? []).length > 1 && (
<Button
buttonType="warning"
onClick={() => setShowBulkEditModal(true)}
disabled={selectedUsers.length === 0}
>
<PencilIcon />
<span>{intl.formatMessage(messages.bulkedit)}</span>
</Button>
)}
</Table.TH>
</tr>
</thead>
<Table.TBody>
{data?.results.map((user) => (
<tr key={`user-list-${user.id}`} data-testid="user-list-row">
<Table.TD>
{isUserPermsEditable(user.id) && (
<input
type="checkbox"
id={`user-list-select-${user.id}`}
name={`user-list-select-${user.id}`}
checked={isUserSelected(user.id)}
onChange={() => {
toggleUser(user.id);
}}
/>
)}
</Table.TD>
<Table.TD>
<div className="flex items-center">
<Link href={`/users/${user.id}`}>
<a className="h-10 w-10 flex-shrink-0">
<img
className="h-10 w-10 rounded-full object-cover"
src={user.avatar}
alt=""
/>
</a>
</Link>
<div className="ml-4">
<Link href={`/users/${user.id}`}>
<a className="text-base font-bold leading-5 transition duration-300 hover:underline">
{user.displayName}
</a>
</Link>
{user.displayName.toLowerCase() !== user.email && (
<div className="text-sm leading-5 text-gray-300">
{user.email}
</div>
)}
</div>
</div>
</Table.TD>
<Table.TD>
{user.id === currentUser?.id ||
currentHasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
) ? (
<Link href={`/users/${user.id}/requests`}>
<a className="text-sm leading-5 transition duration-300 hover:underline">
{user.requestCount}
</a>
</Link>
) : (
user.requestCount
)}
</Table.TD>
<Table.TD>
{user.userType === UserType.PLEX ? (
<Badge badgeType="warning">
{intl.formatMessage(messages.plexuser)}
</Badge>
) : (
<Badge badgeType="default">
{intl.formatMessage(messages.localuser)}
</Badge>
)}
</Table.TD>
<Table.TD>
{user.id === 1
? intl.formatMessage(messages.owner)
: hasPermission(Permission.ADMIN, user.permissions)
? intl.formatMessage(messages.admin)
: intl.formatMessage(messages.user)}
</Table.TD>
<Table.TD>
{intl.formatDate(user.createdAt, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</Table.TD>
<Table.TD alignText="right">
<Button
buttonType="warning"
disabled={user.id === 1 && currentUser?.id !== 1}
className="mr-2"
onClick={() =>
router.push(
'/users/[userId]/settings',
`/users/${user.id}/settings`
)
}
>
{intl.formatMessage(globalMessages.edit)}
</Button>
<Button
buttonType="danger"
disabled={
user.id === 1 ||
(currentUser?.id !== 1 &&
hasPermission(Permission.ADMIN, user.permissions))
}
onClick={() => setDeleteModal({ isOpen: true, user })}
>
{intl.formatMessage(globalMessages.delete)}
</Button>
</Table.TD>
</tr>
))}
<tr className="bg-gray-700">
<Table.TD colSpan={8} noPadding>
<nav
className="flex w-screen flex-col items-center space-x-4 space-y-3 px-6 py-3 sm:flex-row sm:space-y-0 lg:w-full"
aria-label="Pagination"
>
<div className="hidden lg:flex lg:flex-1">
<p className="text-sm">
{data.results.length > 0 &&
intl.formatMessage(globalMessages.showingresults, {
from: pageIndex * currentPageSize + 1,
to:
data.results.length < currentPageSize
? pageIndex * currentPageSize + data.results.length
: (pageIndex + 1) * currentPageSize,
total: data.pageInfo.results,
strong: function strong(msg) {
return <span className="font-medium">{msg}</span>;
},
})}
</p>
</div>
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
<span className="-mt-3 items-center text-sm sm:-ml-4 sm:mt-0 lg:ml-0">
{intl.formatMessage(globalMessages.resultsperpage, {
pageSize: (
<select
id="pageSize"
name="pageSize"
onChange={(e) => {
setCurrentPageSize(Number(e.target.value));
router
.push(router.pathname)
.then(() => window.scrollTo(0, 0));
}}
value={currentPageSize}
className="short inline"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
),
})}
</span>
</div>
<div className="flex flex-auto justify-center space-x-2 sm:flex-1 sm:justify-end">
<Button
disabled={!hasPrevPage}
onClick={() =>
updateQueryParams('page', (page - 1).toString())
}
>
<ChevronLeftIcon />
<span>{intl.formatMessage(globalMessages.previous)}</span>
</Button>
<Button
disabled={!hasNextPage}
onClick={() =>
updateQueryParams('page', (page + 1).toString())
}
>
<span>{intl.formatMessage(globalMessages.next)}</span>
<ChevronRightIcon />
</Button>
</div>
</nav>
</Table.TD>
</tr>
</Table.TBody>
</Table>
</>
);
};
export default UserList;