mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat(ui): Add user requests page (#936)
* feat(ui): add user requests page * fix: return error if user attempts to fetch another user's requests without adequate perms * fix(ui): make user name on request page link back to user profile * feat(ui): link user request count to their filtered request list view * fix(frontend): only display user requests on profiles if current user has adequate perms * fix: use 'all' filter for user-filtered request list * fix(frontend): pass userId to router.push() * fix: do not pass userId in query for non-user-filtered requests page * fix(frontend): also allow REQUEST_VIEW perm through route guard * fix(frontend): only link request count to user request list if current user has required perms
This commit is contained in:
@@ -3957,6 +3957,8 @@ paths:
|
||||
summary: Get all requests
|
||||
description: |
|
||||
Returns all requests if the user has the `ADMIN` or `MANAGE_REQUESTS` permissions. Otherwise, only the logged-in user's requests are returned.
|
||||
|
||||
If the `requestedBy` parameter is specified, only requests from that particular user ID will be returned.
|
||||
tags:
|
||||
- request
|
||||
parameters:
|
||||
@@ -3984,6 +3986,12 @@ paths:
|
||||
type: string
|
||||
enum: [added, modified]
|
||||
default: added
|
||||
- in: query
|
||||
name: requestedBy
|
||||
schema:
|
||||
type: number
|
||||
nullable: true
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Requests returned
|
||||
@@ -4593,7 +4601,7 @@ paths:
|
||||
type: number
|
||||
/media:
|
||||
get:
|
||||
summary: Return media
|
||||
summary: Get media
|
||||
description: Returns all media (can be filtered and limited) in a JSON object.
|
||||
tags:
|
||||
- media
|
||||
|
@@ -17,6 +17,9 @@ requestRoutes.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 10;
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
const requestedBy = req.query.requestedBy
|
||||
? Number(req.query.requestedBy)
|
||||
: null;
|
||||
|
||||
let statusFilter: MediaRequestStatus[];
|
||||
|
||||
@@ -100,9 +103,20 @@ requestRoutes.get('/', async (req, res, next) => {
|
||||
{ type: 'or' }
|
||||
)
|
||||
) {
|
||||
if (requestedBy && requestedBy !== req.user?.id) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: "You do not have permission to view this user's requests.",
|
||||
});
|
||||
}
|
||||
|
||||
query = query.andWhere('requestedBy.id = :id', {
|
||||
id: req.user?.id,
|
||||
});
|
||||
} else if (requestedBy) {
|
||||
query = query.andWhere('requestedBy.id = :id', {
|
||||
id: requestedBy,
|
||||
});
|
||||
}
|
||||
|
||||
const [requests, requestCount] = await query
|
||||
|
@@ -1,8 +1,10 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Button from '../Common/Button';
|
||||
import Header from '../Common/Header';
|
||||
@@ -31,6 +33,9 @@ type Sort = 'added' | 'modified';
|
||||
const RequestList: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { user } = useUser({
|
||||
id: Number(router.query.userId),
|
||||
});
|
||||
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.PENDING);
|
||||
const [currentSort, setCurrentSort] = useState<Sort>('added');
|
||||
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
||||
@@ -41,7 +46,9 @@ const RequestList: React.FC = () => {
|
||||
const { data, error, revalidate } = useSWR<RequestResultsResponse>(
|
||||
`/api/v1/request?take=${currentPageSize}&skip=${
|
||||
pageIndex * currentPageSize
|
||||
}&filter=${currentFilter}&sort=${currentSort}`
|
||||
}&filter=${currentFilter}&sort=${currentSort}${
|
||||
router.query.userId ? `&requestedBy=${router.query.userId}` : ''
|
||||
}`
|
||||
);
|
||||
|
||||
// Restore last set filter values on component mount
|
||||
@@ -87,9 +94,26 @@ const RequestList: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.requests)} />
|
||||
<PageTitle
|
||||
title={[
|
||||
intl.formatMessage(messages.requests),
|
||||
router.query.userId ? user?.displayName : '',
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-col justify-between mb-4 lg:items-end lg:flex-row">
|
||||
<Header>{intl.formatMessage(messages.requests)}</Header>
|
||||
<Header
|
||||
subtext={
|
||||
router.query.userId ? (
|
||||
<Link href={`/users/${user?.id}`}>
|
||||
<a className="hover:underline">{user?.displayName}</a>
|
||||
</Link>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
>
|
||||
{intl.formatMessage(messages.requests)}
|
||||
</Header>
|
||||
<div className="flex flex-col flex-grow mt-2 sm:flex-row lg:flex-grow-0">
|
||||
<div className="flex flex-grow mb-2 sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||
<span className="inline-flex items-center px-3 text-sm text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md">
|
||||
@@ -111,7 +135,12 @@ const RequestList: React.FC = () => {
|
||||
name="filter"
|
||||
onChange={(e) => {
|
||||
setCurrentFilter(e.target.value as Filter);
|
||||
router.push(router.pathname);
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: router.query.userId
|
||||
? { userId: router.query.userId }
|
||||
: {},
|
||||
});
|
||||
}}
|
||||
value={currentFilter}
|
||||
className="rounded-r-only"
|
||||
@@ -152,7 +181,12 @@ const RequestList: React.FC = () => {
|
||||
name="sort"
|
||||
onChange={(e) => {
|
||||
setCurrentSort(e.target.value as Sort);
|
||||
router.push(router.pathname);
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: router.query.userId
|
||||
? { userId: router.query.userId }
|
||||
: {},
|
||||
});
|
||||
}}
|
||||
value={currentSort}
|
||||
className="rounded-r-only"
|
||||
@@ -226,7 +260,12 @@ const RequestList: React.FC = () => {
|
||||
onChange={(e) => {
|
||||
setCurrentPageSize(Number(e.target.value));
|
||||
router
|
||||
.push(router.pathname)
|
||||
.push({
|
||||
pathname: router.pathname,
|
||||
query: router.query.userId
|
||||
? { userId: router.query.userId }
|
||||
: {},
|
||||
})
|
||||
.then(() => window.scrollTo(0, 0));
|
||||
}}
|
||||
value={currentPageSize}
|
||||
@@ -247,9 +286,18 @@ const RequestList: React.FC = () => {
|
||||
disabled={!hasPrevPage}
|
||||
onClick={() =>
|
||||
router
|
||||
.push(`${router.pathname}?page=${page - 1}`, undefined, {
|
||||
.push(
|
||||
{
|
||||
pathname: `${router.pathname}?page=${page - 1}`,
|
||||
query: router.query.userId
|
||||
? { userId: router.query.userId }
|
||||
: {},
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
shallow: true,
|
||||
})
|
||||
}
|
||||
)
|
||||
.then(() => window.scrollTo(0, 0))
|
||||
}
|
||||
>
|
||||
@@ -259,9 +307,18 @@ const RequestList: React.FC = () => {
|
||||
disabled={!hasNextPage}
|
||||
onClick={() =>
|
||||
router
|
||||
.push(`${router.pathname}?page=${page + 1}`, undefined, {
|
||||
.push(
|
||||
{
|
||||
pathname: `${router.pathname}?page=${page + 1}`,
|
||||
query: router.query.userId
|
||||
? { userId: router.query.userId }
|
||||
: {},
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
shallow: true,
|
||||
})
|
||||
}
|
||||
)
|
||||
.then(() => window.scrollTo(0, 0))
|
||||
}
|
||||
>
|
||||
|
@@ -101,7 +101,7 @@ const UserList: React.FC = () => {
|
||||
});
|
||||
const [showBulkEditModal, setShowBulkEditModal] = useState(false);
|
||||
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
|
||||
const { user: currentUser } = useUser();
|
||||
const { user: currentUser, hasPermission: currentHasPermission } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
const filterString = window.localStorage.getItem('ul-filter-settings');
|
||||
@@ -538,7 +538,7 @@ const UserList: React.FC = () => {
|
||||
</Link>
|
||||
<div className="ml-4">
|
||||
<Link href={`/users/${user.id}`}>
|
||||
<a className="text-sm font-medium leading-5">
|
||||
<a className="text-sm font-medium leading-5 transition duration-300 hover:underline">
|
||||
{user.displayName}
|
||||
</a>
|
||||
</Link>
|
||||
@@ -549,7 +549,19 @@ const UserList: React.FC = () => {
|
||||
</div>
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
<div className="text-sm leading-5">{user.requestCount}</div>
|
||||
{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 ? (
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
@@ -225,18 +226,39 @@ const UserProfile: React.FC = () => {
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative z-40 mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-flex items-center text-xl leading-7 text-gray-300 cursor-default sm:text-2xl sm:leading-9 sm:truncate">
|
||||
{(user.id === currentUser?.id ||
|
||||
currentHasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
)) && (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<Link href={`/users/${user?.id}/requests?filter=all`}>
|
||||
<a className="slider-title">
|
||||
<span>{intl.formatMessage(messages.recentrequests)}</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-40">
|
||||
<Slider
|
||||
sliderKey="requests"
|
||||
isLoading={!requests && !requestError}
|
||||
isEmpty={!!requests && !requestError && requests.results.length === 0}
|
||||
isEmpty={
|
||||
!!requests && !requestError && requests.results.length === 0
|
||||
}
|
||||
items={(requests?.results ?? []).map((request) => (
|
||||
<RequestCard
|
||||
key={`request-slider-item-${request.id}`}
|
||||
@@ -247,7 +269,8 @@ const UserProfile: React.FC = () => {
|
||||
placeholder={<RequestCard.Placeholder />}
|
||||
emptyMessage={intl.formatMessage(messages.norequests)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -1,16 +1,19 @@
|
||||
import { Permission, useUser } from './useUser';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { Permission, PermissionCheckOptions, useUser } from './useUser';
|
||||
|
||||
const useRouteGuard = (permission: Permission | Permission[]): void => {
|
||||
const useRouteGuard = (
|
||||
permission: Permission | Permission[],
|
||||
options?: PermissionCheckOptions
|
||||
): void => {
|
||||
const router = useRouter();
|
||||
const { user, hasPermission } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !hasPermission(permission)) {
|
||||
if (user && !hasPermission(permission, options)) {
|
||||
router.push('/');
|
||||
}
|
||||
}, [user, permission, router, hasPermission]);
|
||||
}, [user, permission, router, hasPermission, options]);
|
||||
};
|
||||
|
||||
export default useRouteGuard;
|
||||
|
@@ -1,13 +1,14 @@
|
||||
import useSwr from 'swr';
|
||||
import { MutatorCallback } from 'swr/dist/types';
|
||||
import { UserType } from '../../server/constants/user';
|
||||
import {
|
||||
hasPermission,
|
||||
Permission,
|
||||
PermissionCheckOptions,
|
||||
} from '../../server/lib/permissions';
|
||||
import { UserType } from '../../server/constants/user';
|
||||
import { MutatorCallback } from 'swr/dist/types';
|
||||
|
||||
export { Permission, UserType };
|
||||
export type { PermissionCheckOptions };
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
|
14
src/pages/users/[userId]/requests.tsx
Normal file
14
src/pages/users/[userId]/requests.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextPage } from 'next';
|
||||
import React from 'react';
|
||||
import RequestList from '../../../components/RequestList';
|
||||
import useRouteGuard from '../../../hooks/useRouteGuard';
|
||||
import { Permission } from '../../../hooks/useUser';
|
||||
|
||||
const UserRequestsPage: NextPage = () => {
|
||||
useRouteGuard([Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], {
|
||||
type: 'or',
|
||||
});
|
||||
return <RequestList />;
|
||||
};
|
||||
|
||||
export default UserRequestsPage;
|
Reference in New Issue
Block a user