diff --git a/package.json b/package.json index 15d3c5076..f3af6e4cb 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "license": "MIT", "dependencies": { + "@headlessui/react": "^0.2.0-da179ca", "@supercharge/request-ip": "^1.1.2", "@svgr/webpack": "^5.5.0", "ace-builds": "^1.4.12", diff --git a/server/routes/request.ts b/server/routes/request.ts index 2a5e7c415..d932b1dac 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -9,6 +9,7 @@ import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media'; import SeasonRequest from '../entity/SeasonRequest'; import logger from '../logger'; import { RequestResultsResponse } from '../interfaces/api/requestInterfaces'; +import { User } from '../entity/User'; const requestRoutes = Router(); @@ -94,8 +95,28 @@ requestRoutes.post( const tmdb = new TheMovieDb(); const mediaRepository = getRepository(Media); const requestRepository = getRepository(MediaRequest); + const userRepository = getRepository(User); try { + let requestUser = req.user; + + if ( + req.body.userId && + !( + req.user?.hasPermission(Permission.MANAGE_USERS) && + req.user?.hasPermission(Permission.MANAGE_REQUESTS) + ) + ) { + return next({ + status: 403, + message: 'You do not have permission to modify the request user.', + }); + } else if (req.body.userId) { + requestUser = await userRepository.findOneOrFail({ + where: { id: req.body.userId }, + }); + } + const tmdbMedia = req.body.mediaType === 'movie' ? await tmdb.getMovie({ movieId: req.body.mediaId }) @@ -151,7 +172,7 @@ requestRoutes.post( const request = new MediaRequest({ type: MediaType.MOVIE, media, - requestedBy: req.user, + requestedBy: requestUser, // If the user is an admin or has the "auto approve" permission, automatically approve the request status: req.user?.hasPermission(Permission.AUTO_APPROVE) || @@ -212,7 +233,7 @@ requestRoutes.post( media: { id: media.id, } as Media, - requestedBy: req.user, + requestedBy: requestUser, // If the user is an admin or has the "auto approve" permission, automatically approve the request status: req.user?.hasPermission(Permission.AUTO_APPROVE) || @@ -292,6 +313,7 @@ requestRoutes.put<{ requestId: string }>( isAuthenticated(Permission.MANAGE_REQUESTS), async (req, res, next) => { const requestRepository = getRepository(MediaRequest); + const userRepository = getRepository(User); try { const request = await requestRepository.findOne( Number(req.params.requestId) @@ -301,10 +323,30 @@ requestRoutes.put<{ requestId: string }>( return next({ status: 404, message: 'Request not found' }); } + let requestUser = req.user; + + if ( + req.body.userId && + !( + req.user?.hasPermission(Permission.MANAGE_USERS) && + req.user?.hasPermission(Permission.MANAGE_REQUESTS) + ) + ) { + return next({ + status: 403, + message: 'You do not have permission to modify the request user.', + }); + } else if (req.body.userId) { + requestUser = await userRepository.findOneOrFail({ + where: { id: req.body.userId }, + }); + } + if (req.body.mediaType === 'movie') { request.serverId = req.body.serverId; request.profileId = req.body.profileId; request.rootFolder = req.body.rootFolder; + request.requestedBy = requestUser as User; requestRepository.save(request); } else if (req.body.mediaType === 'tv') { @@ -312,6 +354,7 @@ requestRoutes.put<{ requestId: string }>( request.serverId = req.body.serverId; request.profileId = req.body.profileId; request.rootFolder = req.body.rootFolder; + request.requestedBy = requestUser as User; const requestedSeasons = req.body.seasons as number[] | undefined; diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx index bf6911edc..435c3849a 100644 --- a/src/components/RequestModal/AdvancedRequester/index.tsx +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -8,6 +8,8 @@ import type { } from '../../../../server/interfaces/api/serviceInterfaces'; import { defineMessages, useIntl } from 'react-intl'; import { formatBytes } from '../../../utils/numberHelpers'; +import { Listbox, Transition } from '@headlessui/react'; +import { Permission, User, useUser } from '../../../hooks/useUser'; const messages = defineMessages({ advancedoptions: 'Advanced Options', @@ -18,12 +20,14 @@ const messages = defineMessages({ default: '(Default)', loadingprofiles: 'Loading profiles…', loadingfolders: 'Loading folders…', + requestas: 'Request As', }); export type RequestOverrides = { server?: number; profile?: number; folder?: string; + user?: User; }; interface AdvancedRequesterProps { @@ -31,6 +35,7 @@ interface AdvancedRequesterProps { is4k: boolean; isAnime?: boolean; defaultOverrides?: RequestOverrides; + requestUser?: User; onChange: (overrides: RequestOverrides) => void; } @@ -39,9 +44,11 @@ const AdvancedRequester: React.FC = ({ is4k = false, isAnime = false, defaultOverrides, + requestUser, onChange, }) => { const intl = useIntl(); + const { user, hasPermission } = useUser(); const { data, error } = useSWR( `/api/v1/service/${type === 'movie' ? 'radarr' : 'sonarr'}`, { @@ -78,6 +85,22 @@ const AdvancedRequester: React.FC = ({ } ); + const [selectedUser, setSelectedUser] = useState( + requestUser ?? null + ); + + const { data: userData } = useSWR( + hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS]) + ? '/api/v1/user' + : null + ); + + useEffect(() => { + if (userData && !requestUser) { + setSelectedUser(userData.find((u) => u.id === user?.id) ?? null); + } + }, [userData]); + useEffect(() => { let defaultServer = data?.find( (server) => server.isDefault && is4k === server.is4k @@ -167,9 +190,10 @@ const AdvancedRequester: React.FC = ({ folder: selectedFolder !== '' ? selectedFolder : undefined, profile: selectedProfile !== -1 ? selectedProfile : undefined, server: selectedServer ?? undefined, + user: selectedUser ?? undefined, }); } - }, [selectedFolder, selectedServer, selectedProfile]); + }, [selectedFolder, selectedServer, selectedProfile, selectedUser]); if (!data && !error) { return ( @@ -288,6 +312,130 @@ const AdvancedRequester: React.FC = ({ + {hasPermission(Permission.MANAGE_REQUESTS) && + hasPermission(Permission.MANAGE_USERS) && + selectedUser && ( +
+ setSelectedUser(value)} + className="space-y-1" + > + {({ open }) => ( + <> + + {intl.formatMessage(messages.requestas)} + +
+ + + + + + {selectedUser.displayName} + + + ({selectedUser.email}) + + + + + + + + + + + + + {userData?.map((user) => ( + + {({ selected, active }) => ( +
+ + + + {user.displayName} + + + ({user.email}) + + + {selected && ( + + + + + + )} +
+ )} +
+ ))} +
+
+
+ + )} +
+
+ )} {isAnime && (
{intl.formatMessage(messages.animenote)} diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index 5b50e6b2d..16fc67d92 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -87,6 +87,7 @@ const MovieRequestModal: React.FC = ({ serverId: requestOverrides.server, profileId: requestOverrides.profile, rootFolder: requestOverrides.folder, + userId: requestOverrides.user?.id, }; } const response = await axios.post('/api/v1/request', { @@ -169,6 +170,7 @@ const MovieRequestModal: React.FC = ({ serverId: requestOverrides?.server, profileId: requestOverrides?.profile, rootFolder: requestOverrides?.folder, + userId: requestOverrides?.user?.id, }); addToast({intl.formatMessage(messages.requestedited)}, { @@ -227,11 +229,13 @@ const MovieRequestModal: React.FC = ({ username: activeRequest.requestedBy.displayName, } )} - {hasPermission(Permission.REQUEST_ADVANCED) && ( + {(hasPermission(Permission.REQUEST_ADVANCED) || + hasPermission(Permission.MANAGE_REQUESTS)) && (
= ({

)} - {hasPermission(Permission.REQUEST_ADVANCED) && ( + {(hasPermission(Permission.REQUEST_ADVANCED) || + hasPermission(Permission.MANAGE_REQUESTS)) && ( = ({ serverId: requestOverrides?.server, profileId: requestOverrides?.profile, rootFolder: requestOverrides?.folder, + userId: requestOverrides?.user?.id, seasons: selectedSeasons, }); } else { @@ -150,6 +151,7 @@ const TvRequestModal: React.FC = ({ serverId: requestOverrides.server, profileId: requestOverrides.profile, rootFolder: requestOverrides.folder, + userId: requestOverrides?.user?.id, }; } const response = await axios.post('/api/v1/request', { @@ -391,7 +393,7 @@ const TvRequestModal: React.FC = ({ toggleAllSeasons(); } }} - className="relative inline-flex items-center justify-center flex-shrink-0 w-10 h-5 cursor-pointer pt-2 focus:outline-none" + className="relative inline-flex items-center justify-center flex-shrink-0 w-10 h-5 pt-2 cursor-pointer focus:outline-none" >
- {hasPermission(Permission.REQUEST_ADVANCED) && ( + {(hasPermission(Permission.REQUEST_ADVANCED) || + hasPermission(Permission.MANAGE_REQUESTS)) && (
= ({ (keyword) => keyword.id === ANIME_KEYWORD_ID )} onChange={(overrides) => setRequestOverrides(overrides)} + requestUser={editRequest?.requestedBy} defaultOverrides={ editRequest ? { diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index b41f64d99..ea646610f 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -169,6 +169,7 @@ "components.RequestModal.AdvancedRequester.loadingfolders": "Loading folders…", "components.RequestModal.AdvancedRequester.loadingprofiles": "Loading profiles…", "components.RequestModal.AdvancedRequester.qualityprofile": "Quality Profile", + "components.RequestModal.AdvancedRequester.requestas": "Request As", "components.RequestModal.AdvancedRequester.rootfolder": "Root Folder", "components.RequestModal.SearchByNameModal.next": "Next", "components.RequestModal.SearchByNameModal.nosummary": "No summary for this title was found.", diff --git a/yarn.lock b/yarn.lock index 00ca4d54a..33261b9be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1481,6 +1481,11 @@ resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.1.0.tgz#6c9eafc78c1529248f8f4d92b0799a712b6052c6" integrity sha512-i9YbZPN3QgfighY/1X1Pu118VUz2Fmmhd6b2n0/O8YVgGGfw0FbUYoA97k7FkpGJ+pLCFEDLUmAPPV4D1kpeFw== +"@headlessui/react@^0.2.0-da179ca": + version "0.2.0-da179ca" + resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-0.2.0-da179ca.tgz#208bfd2cc844196282798376eec7e5e9839fc3f2" + integrity sha512-KgQNicrW7Iphc2RJS54vUvb5IVcllopJkffCUT7z9BMHboazV0Hw28GZubZBA05bxZUc1k42Fhpb5wpxe1bDJw== + "@iarna/cli@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@iarna/cli/-/cli-1.2.0.tgz#0f7af5e851afe895104583c4ca07377a8094d641"