import Alert from '@app/components/Common/Alert'; import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import Header from '@app/components/Common/Header'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import Modal from '@app/components/Common/Modal'; import PageTitle from '@app/components/Common/PageTitle'; import SensitiveInput from '@app/components/Common/SensitiveInput'; import Table from '@app/components/Common/Table'; import BulkEditModal from '@app/components/UserList/BulkEditModal'; import PlexImportModal from '@app/components/UserList/PlexImportModal'; import useSettings from '@app/hooks/useSettings'; import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; import type { User } from '@app/hooks/useUser'; import { Permission, UserType, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; import { BarsArrowDownIcon, ChevronLeftIcon, ChevronRightIcon, InboxArrowDownIcon, PencilIcon, UserPlusIcon, } from '@heroicons/react/24/solid'; import { MediaServerType } from '@server/constants/server'; import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'; import { hasPermission } from '@server/lib/permissions'; import { Field, Form, Formik } from 'formik'; import getConfig from 'next/config'; import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import * as Yup from 'yup'; import JellyfinImportModal from './JellyfinImportModal'; const messages = defineMessages('components.UserList', { users: 'Users', userlist: 'User List', importfrommediaserver: 'Import {mediaServerName} 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', mediaServerUser: '{mediaServerName} 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 Enable Local Sign-In setting is currently disabled.', }); type Sort = 'created' | 'updated' | 'requests' | 'displayname'; const UserList = () => { const intl = useIntl(); const router = useRouter(); const settings = useSettings(); const { publicRuntimeConfig } = getConfig(); const { addToast } = useToasts(); const { user: currentUser, hasPermission: currentHasPermission } = useUser(); const [currentSort, setCurrentSort] = useState('displayname'); const [currentPageSize, setCurrentPageSize] = useState(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( `/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([]); 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 { const res = await fetch(`/api/v1/user/${deleteModal.user?.id}`, { method: 'DELETE', }); if (!res.ok) throw new Error(); addToast(intl.formatMessage(messages.userdeleted), { autoDismiss: true, appearance: 'success', }); setDeleteModal({ isOpen: false, user: deleteModal.user }); } catch (e) { addToast(intl.formatMessage(messages.userdeleteerror), { autoDismiss: true, appearance: 'error', }); } finally { setDeleting(false); revalidate(); } }; if (!data && !error) { return ; } 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 ; } const hasNextPage = data.pageInfo.pages > pageIndex + 1; const hasPrevPage = pageIndex > 0; const passwordGenerationEnabled = settings.currentSettings.applicationUrl && settings.currentSettings.emailEnabled; return ( <> deleteUser()} okText={ isDeleting ? intl.formatMessage(globalMessages.deleting) : intl.formatMessage(globalMessages.delete) } okDisabled={isDeleting} okButtonType="danger" onCancel={() => setDeleteModal({ isOpen: false, user: deleteModal.user }) } title={intl.formatMessage(messages.deleteuser)} subTitle={deleteModal.user?.displayName} > {intl.formatMessage(messages.deleteconfirm)} { try { const res = await fetch('/api/v1/user', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ username: values.displayName, email: values.email, password: values.genpassword ? null : values.password, }), }); if (!res.ok) throw new Error(res.statusText, { cause: res }); addToast(intl.formatMessage(messages.usercreatedsuccess), { appearance: 'success', autoDismiss: true, }); setCreateModal({ isOpen: false }); } catch (e) { let errorData; try { errorData = await e.cause?.text(); errorData = JSON.parse(errorData); } catch { /* empty */ } addToast( intl.formatMessage( errorData.errors?.includes('USER_EXISTS') ? messages.usercreatedfailedexisting : messages.usercreatedfailed ), { appearance: 'error', autoDismiss: true, } ); } finally { revalidate(); } }} > {({ errors, touched, isSubmitting, values, isValid, setFieldValue, handleSubmit, }) => { return ( handleSubmit()} okText={ isSubmitting ? intl.formatMessage(messages.creating) : intl.formatMessage(messages.create) } okDisabled={isSubmitting || !isValid} okButtonType="primary" onCancel={() => setCreateModal({ isOpen: false })} > {!settings.currentSettings.localLogin && ( ( {msg} ), })} type="warning" /> )} {currentHasPermission(Permission.ADMIN) && !passwordGenerationEnabled && ( )} {intl.formatMessage(messages.displayName)} {intl.formatMessage(messages.email)} * {errors.email && touched.email && typeof errors.email === 'string' && ( {errors.email} )} {intl.formatMessage(messages.autogeneratepassword)} {intl.formatMessage(messages.autogeneratepasswordTip)} setFieldValue('password', '')} /> {intl.formatMessage(messages.password)} {!values.genpassword && ( * )} {errors.password && touched.password && typeof errors.password === 'string' && ( {errors.password} )} ); }} setShowBulkEditModal(false)} onComplete={() => { setShowBulkEditModal(false); revalidate(); }} selectedUserIds={selectedUsers} users={data.results} /> {settings.currentSettings.mediaServerType === MediaServerType.PLEX ? ( setShowImportModal(false)} onComplete={() => { setShowImportModal(false); revalidate(); }} /> ) : ( setShowImportModal(false)} onComplete={() => { setShowImportModal(false); revalidate(); }} > {data.pageInfo.results} )} {intl.formatMessage(messages.userlist)} setCreateModal({ isOpen: true })} > {intl.formatMessage(messages.createlocaluser)} setShowImportModal(true)} > {publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? intl.formatMessage(messages.importfrommediaserver, { mediaServerName: 'Emby', }) : settings.currentSettings.mediaServerType === MediaServerType.PLEX ? intl.formatMessage(messages.importfrommediaserver, { mediaServerName: 'Plex', }) : intl.formatMessage(messages.importfrommediaserver, { mediaServerName: 'Jellyfin', })} { setCurrentSort(e.target.value as Sort); router.push(router.pathname); }} value={currentSort} className="rounded-r-only" > {intl.formatMessage(messages.sortCreated)} {intl.formatMessage(messages.sortRequests)} {intl.formatMessage(messages.sortDisplayName)} {(data.results ?? []).length > 1 && ( { toggleAllUsers(); }} /> )} {intl.formatMessage(messages.user)} {intl.formatMessage(messages.totalrequests)} {intl.formatMessage(messages.accounttype)} {intl.formatMessage(messages.role)} {intl.formatMessage(messages.created)} {(data.results ?? []).length > 1 && ( setShowBulkEditModal(true)} disabled={selectedUsers.length === 0} > {intl.formatMessage(messages.bulkedit)} )} {data?.results.map((user) => ( {isUserPermsEditable(user.id) && ( { toggleUser(user.id); }} /> )} {user.displayName} {user.displayName.toLowerCase() !== user.email && ( {user.email} )} {user.id === currentUser?.id || currentHasPermission( [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], { type: 'or' } ) ? ( {user.requestCount} ) : ( user.requestCount )} {user.userType === UserType.PLEX ? ( {intl.formatMessage(messages.plexuser)} ) : user.userType === UserType.LOCAL ? ( {intl.formatMessage(messages.localuser)} ) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? ( {intl.formatMessage(messages.mediaServerUser, { mediaServerName: 'Emby', })} ) : user.userType === UserType.JELLYFIN ? ( {intl.formatMessage(messages.mediaServerUser, { mediaServerName: 'Jellyfin', })} ) : null} {user.id === 1 ? intl.formatMessage(messages.owner) : hasPermission(Permission.ADMIN, user.permissions) ? intl.formatMessage(messages.admin) : intl.formatMessage(messages.user)} {intl.formatDate(user.createdAt, { year: 'numeric', month: 'long', day: 'numeric', })} router.push( '/users/[userId]/settings', `/users/${user.id}/settings` ) } > {intl.formatMessage(globalMessages.edit)} setDeleteModal({ isOpen: true, user })} > {intl.formatMessage(globalMessages.delete)} ))} {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: (msg: React.ReactNode) => ( {msg} ), })} {intl.formatMessage(globalMessages.resultsperpage, { pageSize: ( { setCurrentPageSize(Number(e.target.value)); router .push(router.pathname) .then(() => window.scrollTo(0, 0)); }} value={currentPageSize} className="short inline" > 5 10 25 50 100 ), })} updateQueryParams('page', (page - 1).toString()) } > {intl.formatMessage(globalMessages.previous)} updateQueryParams('page', (page + 1).toString()) } > {intl.formatMessage(globalMessages.next)} > ); }; export default UserList;
{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: (msg: React.ReactNode) => ( {msg} ), })}